Futher changes to bring refactor up to feature parity + Updated Docs
This commit is contained in:
parent
deeb1595fd
commit
beb8640085
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -8,6 +8,6 @@
|
|||||||
".vscode": true,
|
".vscode": true,
|
||||||
".vs": true,
|
".vs": true,
|
||||||
".lh": true,
|
".lh": true,
|
||||||
"__pycache__": true,
|
"__pycache__": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -12,9 +12,9 @@ This documentation strictly excludes details on environment setup, dependency in
|
|||||||
|
|
||||||
## Architecture and Codebase Summary
|
## Architecture and Codebase Summary
|
||||||
|
|
||||||
For developers interested in contributing, the tool's architecture centers on a **Core Processing Engine** (`processing_engine.py`) executing a pipeline based on a **Hierarchical Rule System** (`rule_structure.py`) and a **Configuration System** (`configuration.py` loading `config/app_settings.json` and `Presets/*.json`). The **Graphical User Interface** (`gui/`) has been significantly refactored: `MainWindow` (`main_window.py`) acts as a coordinator, delegating tasks to specialized widgets (`MainPanelWidget`, `PresetEditorWidget`, `LogConsoleWidget`) and background handlers (`RuleBasedPredictionHandler`, `LLMPredictionHandler`, `LLMInteractionHandler`, `AssetRestructureHandler`). The **Directory Monitor** (`monitor.py`) now processes archives asynchronously using a thread pool and utility functions (`utils/prediction_utils.py`, `utils/workspace_utils.py`). The **Command-Line Interface** entry point (`main.py`) primarily launches the GUI, with core CLI functionality currently non-operational. Optional **Blender Integration** (`blenderscripts/`) remains. A new `utils/` directory houses shared helper functions.
|
For developers interested in contributing, the tool's architecture centers on a **Core Processing Engine** (`processing_engine.py`) which initializes and runs a **Pipeline Orchestrator** (`processing/pipeline/orchestrator.py::PipelineOrchestrator`). This orchestrator executes a defined sequence of **Processing Stages** (located in `processing/pipeline/stages/`) based on a **Hierarchical Rule System** (`rule_structure.py`) and a **Configuration System** (`configuration.py` loading `config/app_settings.json` and `Presets/*.json`). The **Graphical User Interface** (`gui/`) has been significantly refactored: `MainWindow` (`main_window.py`) acts as a coordinator, delegating tasks to specialized widgets (`MainPanelWidget`, `PresetEditorWidget`, `LogConsoleWidget`) and background handlers (`RuleBasedPredictionHandler`, `LLMPredictionHandler`, `LLMInteractionHandler`, `AssetRestructureHandler`). The **Directory Monitor** (`monitor.py`) now processes archives asynchronously using a thread pool and utility functions (`utils/prediction_utils.py`, `utils/workspace_utils.py`). The **Command-Line Interface** entry point (`main.py`) primarily launches the GUI, with core CLI functionality currently non-operational. Optional **Blender Integration** (`blenderscripts/`) remains. A new `utils/` directory houses shared helper functions.
|
||||||
|
|
||||||
The codebase reflects this structure. The `gui/` directory contains the refactored UI components, `utils/` holds shared utilities, `Presets/` contains JSON presets, and `blenderscripts/` holds Blender scripts. Core logic resides in `processing_engine.py`, `configuration.py`, `rule_structure.py`, `monitor.py`, and `main.py`. The processing pipeline, executed by `processing_engine.py`, relies entirely on the input `SourceRule` and static configuration for steps like map processing, channel merging, and metadata generation.
|
The codebase reflects this structure. The `gui/` directory contains the refactored UI components, `utils/` holds shared utilities, `processing/pipeline/` contains the orchestrator and individual processing stages, `Presets/` contains JSON presets, and `blenderscripts/` holds Blender scripts. Core logic resides in `processing_engine.py`, `processing/pipeline/orchestrator.py`, `configuration.py`, `rule_structure.py`, `monitor.py`, and `main.py`. The processing pipeline, initiated by `processing_engine.py` and executed by the `PipelineOrchestrator`, relies entirely on the input `SourceRule` and static configuration. Each stage in the pipeline operates on an `AssetProcessingContext` object (`processing/pipeline/asset_context.py`) to perform specific tasks like map processing, channel merging, and metadata generation.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
|||||||
@ -6,17 +6,19 @@ This document provides a high-level overview of the Asset Processor Tool's archi
|
|||||||
|
|
||||||
The Asset Processor Tool is designed to process 3D asset source files into a standardized library format. Its high-level architecture consists of:
|
The Asset Processor Tool is designed to process 3D asset source files into a standardized library format. Its high-level architecture consists of:
|
||||||
|
|
||||||
1. **Core Processing Engine (`processing_engine.py`):** The primary component responsible for executing the asset processing pipeline for a single input asset based on a provided `SourceRule` object and static configuration. The previous `asset_processor.py` has been removed.
|
1. **Core Processing Initiation (`processing_engine.py`):** The `ProcessingEngine` class acts as the entry point for an asset processing task. It initializes and runs a `PipelineOrchestrator`.
|
||||||
2. **Prediction System:** Responsible for analyzing input files and generating the initial `SourceRule` hierarchy with predicted values. This system utilizes a base handler (`gui/base_prediction_handler.py::BasePredictionHandler`) with specific implementations:
|
2. **Pipeline Orchestration (`processing/pipeline/orchestrator.py`):** The `PipelineOrchestrator` manages a sequence of discrete processing stages. It creates an `AssetProcessingContext` for each asset and passes this context through each stage.
|
||||||
|
3. **Processing Stages (`processing/pipeline/stages/`):** Individual modules, each responsible for a specific task in the pipeline (e.g., filtering files, processing maps, merging channels, organizing output). They operate on the `AssetProcessingContext`.
|
||||||
|
4. **Prediction System:** Responsible for analyzing input files and generating the initial `SourceRule` hierarchy with predicted values. This system utilizes a base handler (`gui/base_prediction_handler.py::BasePredictionHandler`) with specific implementations:
|
||||||
* **Rule-Based Predictor (`gui/prediction_handler.py::RuleBasedPredictionHandler`):** Uses predefined rules from presets to classify files and determine initial processing parameters.
|
* **Rule-Based Predictor (`gui/prediction_handler.py::RuleBasedPredictionHandler`):** Uses predefined rules from presets to classify files and determine initial processing parameters.
|
||||||
* **LLM Predictor (`gui/llm_prediction_handler.py::LLMPredictionHandler`):** An experimental alternative that uses a Large Language Model (LLM) to interpret file contents and context to predict processing parameters.
|
* **LLM Predictor (`gui/llm_prediction_handler.py::LLMPredictionHandler`):** An experimental alternative that uses a Large Language Model (LLM) to interpret file contents and context to predict processing parameters.
|
||||||
3. **Configuration System (`Configuration`):** Handles loading core settings (including centralized type definitions and LLM-specific configuration) and merging them with supplier-specific rules defined in JSON presets and the persistent `config/suppliers.json` file.
|
5. **Configuration System (`Configuration`):** Handles loading core settings (including centralized type definitions and LLM-specific configuration) and merging them with supplier-specific rules defined in JSON presets and the persistent `config/suppliers.json` file.
|
||||||
4. **Multiple Interfaces:** Provides different ways to interact with the tool:
|
6. **Multiple Interfaces:** Provides different ways to interact with the tool:
|
||||||
* Graphical User Interface (GUI)
|
* Graphical User Interface (GUI)
|
||||||
* Command-Line Interface (CLI) - *Note: The primary CLI execution logic (`run_cli` in `main.py`) is currently non-functional/commented out post-refactoring.*
|
* Command-Line Interface (CLI) - *Note: The primary CLI execution logic (`run_cli` in `main.py`) is currently non-functional/commented out post-refactoring.*
|
||||||
* Directory Monitor for automated processing.
|
* Directory Monitor for automated processing.
|
||||||
The GUI acts as the primary source of truth for processing rules, coordinating the generation and management of the `SourceRule` hierarchy before sending it to the processing engine. It accumulates prediction results from multiple input sources before updating the view. The Monitor interface can also generate `SourceRule` objects (using `utils/prediction_utils.py`) to bypass the GUI for automated workflows.
|
The GUI acts as the primary source of truth for processing rules, coordinating the generation and management of the `SourceRule` hierarchy before sending it to the `ProcessingEngine`. It accumulates prediction results from multiple input sources before updating the view. The Monitor interface can also generate `SourceRule` objects (using `utils/prediction_utils.py`) to bypass the GUI for automated workflows.
|
||||||
5. **Optional Integration:** Includes scripts (`blenderscripts/`) for integrating with Blender. Logic for executing these scripts was intended to be centralized in `utils/blender_utils.py`, but this utility has not yet been implemented.
|
7. **Optional Integration:** Includes scripts (`blenderscripts/`) for integrating with Blender. Logic for executing these scripts was intended to be centralized in `utils/blender_utils.py`, but this utility has not yet been implemented.
|
||||||
|
|
||||||
## Hierarchical Rule System
|
## Hierarchical Rule System
|
||||||
|
|
||||||
@ -26,14 +28,14 @@ A key addition to the architecture is the **Hierarchical Rule System**, which pr
|
|||||||
* **AssetRule:** Represents rules applied to a specific asset within a source (a source can contain multiple assets).
|
* **AssetRule:** Represents rules applied to a specific asset within a source (a source can contain multiple assets).
|
||||||
* **FileRule:** Represents rules applied to individual files within an asset.
|
* **FileRule:** Represents rules applied to individual files within an asset.
|
||||||
|
|
||||||
This hierarchy allows for fine-grained control over processing parameters. The GUI's prediction logic generates this hierarchy with initial predicted values for overridable fields based on presets and file analysis. The processing engine then operates *solely* on the explicit values provided in this `SourceRule` object and static configuration, without internal prediction or fallback logic.
|
This hierarchy allows for fine-grained control over processing parameters. The GUI's prediction logic generates this hierarchy with initial predicted values for overridable fields based on presets and file analysis. The `ProcessingEngine` (via the `PipelineOrchestrator` and its stages) then operates *solely* on the explicit values provided in this `SourceRule` object and static configuration, without internal prediction or fallback logic.
|
||||||
|
|
||||||
## Core Components
|
## Core Components
|
||||||
|
|
||||||
* `config/app_settings.json`: Defines core, global settings, constants, and centralized definitions for allowed asset and file types (`ASSET_TYPE_DEFINITIONS`, `FILE_TYPE_DEFINITIONS`), including metadata like colors and descriptions. This replaces the old `config.py` file.
|
* `config/app_settings.json`: Defines core, global settings, constants, and centralized definitions for allowed asset and file types (`ASSET_TYPE_DEFINITIONS`, `FILE_TYPE_DEFINITIONS`), including metadata like colors and descriptions. This replaces the old `config.py` file.
|
||||||
* `config/suppliers.json`: A persistent JSON file storing known supplier names for GUI auto-completion.
|
* `config/suppliers.json`: A persistent JSON file storing known supplier names for GUI auto-completion.
|
||||||
* `Presets/*.json`: Supplier-specific JSON files defining rules for file interpretation and initial prediction.
|
* `Presets/*.json`: Supplier-specific JSON files defining rules for file interpretation and initial prediction.
|
||||||
* `configuration.py` (`Configuration` class): Loads `config/app_settings.json` settings and merges them with a selected preset, pre-compiling regex patterns for efficiency. This static configuration is used by the processing engine.
|
* `configuration.py` (`Configuration` class): Loads `config/app_settings.json` settings and merges them with a selected preset, pre-compiling regex patterns for efficiency. This static configuration is used by the processing pipeline.
|
||||||
* `rule_structure.py`: Defines the `SourceRule`, `AssetRule`, and `FileRule` dataclasses used to represent the hierarchical processing rules.
|
* `rule_structure.py`: Defines the `SourceRule`, `AssetRule`, and `FileRule` dataclasses used to represent the hierarchical processing rules.
|
||||||
* `gui/`: Directory containing modules for the Graphical User Interface (GUI), built with PySide6. The `MainWindow` (`main_window.py`) acts as a coordinator, orchestrating interactions between various components. Key GUI components include:
|
* `gui/`: Directory containing modules for the Graphical User Interface (GUI), built with PySide6. The `MainWindow` (`main_window.py`) acts as a coordinator, orchestrating interactions between various components. Key GUI components include:
|
||||||
* `main_panel_widget.py::MainPanelWidget`: Contains the primary controls for loading sources, selecting presets, viewing/editing rules, and initiating processing.
|
* `main_panel_widget.py::MainPanelWidget`: Contains the primary controls for loading sources, selecting presets, viewing/editing rules, and initiating processing.
|
||||||
@ -47,7 +49,10 @@ This hierarchy allows for fine-grained control over processing parameters. The G
|
|||||||
* `prediction_handler.py::RuleBasedPredictionHandler`: Generates the initial `SourceRule` hierarchy based on presets and file analysis. Inherits from `BasePredictionHandler`.
|
* `prediction_handler.py::RuleBasedPredictionHandler`: Generates the initial `SourceRule` hierarchy based on presets and file analysis. Inherits from `BasePredictionHandler`.
|
||||||
* `llm_prediction_handler.py::LLMPredictionHandler`: Experimental predictor using an LLM. Inherits from `BasePredictionHandler`.
|
* `llm_prediction_handler.py::LLMPredictionHandler`: Experimental predictor using an LLM. Inherits from `BasePredictionHandler`.
|
||||||
* `llm_interaction_handler.py::LLMInteractionHandler`: Manages communication with the LLM service for the LLM predictor.
|
* `llm_interaction_handler.py::LLMInteractionHandler`: Manages communication with the LLM service for the LLM predictor.
|
||||||
* `processing_engine.py` (`ProcessingEngine` class): The core component that executes the processing pipeline for a single `SourceRule` object using the static `Configuration`. A new instance is created per task for state isolation.
|
* `processing_engine.py` (`ProcessingEngine` class): The entry-point class that initializes and runs the `PipelineOrchestrator` for a given `SourceRule` and `Configuration`.
|
||||||
|
* `processing/pipeline/orchestrator.py` (`PipelineOrchestrator` class): Manages the sequence of processing stages, creating and passing an `AssetProcessingContext` through them.
|
||||||
|
* `processing/pipeline/asset_context.py` (`AssetProcessingContext` class): A dataclass holding all data and state for the processing of a single asset, passed between stages.
|
||||||
|
* `processing/pipeline/stages/`: Directory containing individual processing stage modules, each handling a specific part of the pipeline (e.g., `IndividualMapProcessingStage`, `MapMergingStage`).
|
||||||
* `main.py`: The main entry point for the application. Primarily launches the GUI. Contains commented-out/non-functional CLI logic (`run_cli`).
|
* `main.py`: The main entry point for the application. Primarily launches the GUI. Contains commented-out/non-functional CLI logic (`run_cli`).
|
||||||
* `monitor.py`: Implements the directory monitoring feature using `watchdog`. It now processes archives asynchronously using a `ThreadPoolExecutor`, leveraging `utils.prediction_utils.py` for rule generation and `utils.workspace_utils.py` for workspace management before invoking the `ProcessingEngine`.
|
* `monitor.py`: Implements the directory monitoring feature using `watchdog`. It now processes archives asynchronously using a `ThreadPoolExecutor`, leveraging `utils.prediction_utils.py` for rule generation and `utils.workspace_utils.py` for workspace management before invoking the `ProcessingEngine`.
|
||||||
* `blenderscripts/`: Contains Python scripts designed to be executed *within* Blender for post-processing tasks.
|
* `blenderscripts/`: Contains Python scripts designed to be executed *within* Blender for post-processing tasks.
|
||||||
@ -56,19 +61,21 @@ This hierarchy allows for fine-grained control over processing parameters. The G
|
|||||||
* `prediction_utils.py`: Contains functions like `generate_source_rule_from_archive` used by the monitor for rule-based prediction.
|
* `prediction_utils.py`: Contains functions like `generate_source_rule_from_archive` used by the monitor for rule-based prediction.
|
||||||
* `blender_utils.py`: (Intended location for Blender script execution logic, currently not implemented).
|
* `blender_utils.py`: (Intended location for Blender script execution logic, currently not implemented).
|
||||||
|
|
||||||
## Processing Pipeline (Simplified)
|
## Processing Pipeline (Simplified Overview)
|
||||||
|
|
||||||
The primary processing engine (`processing_engine.py`) executes a series of steps for each asset based on the provided `SourceRule` object and static configuration:
|
The asset processing pipeline, initiated by `processing_engine.py` and managed by `PipelineOrchestrator`, executes a series of stages for each asset defined in the `SourceRule`. An `AssetProcessingContext` object carries data between stages. The typical sequence is:
|
||||||
|
|
||||||
1. Extraction of input to a temporary workspace (using `utils.workspace_utils.py`).
|
1. **Supplier Determination**: Identify the effective supplier.
|
||||||
2. Classification of files (map, model, extra, ignored, unrecognised) based *only* on the provided `SourceRule` object (classification/prediction happens *before* the engine is called).
|
2. **Asset Skip Logic**: Check if the asset should be skipped.
|
||||||
3. Determination of base metadata (asset name, category, archetype).
|
3. **Metadata Initialization**: Set up initial asset metadata.
|
||||||
4. Skip check if output exists and overwrite is not forced.
|
4. **File Rule Filtering**: Determine which files to process.
|
||||||
5. Processing of maps (resize, format/bit depth conversion, inversion, stats calculation).
|
5. **Pre-Map Processing**:
|
||||||
6. Merging of channels based on rules.
|
* Gloss-to-Roughness Conversion.
|
||||||
7. Generation of `metadata.json` file.
|
* Alpha Channel Extraction.
|
||||||
8. Organization of processed files into the final output structure.
|
* Normal Map Green Channel Inversion.
|
||||||
9. Cleanup of the temporary workspace.
|
6. **Individual Map Processing**: Handle individual maps (scaling, variants, stats, naming).
|
||||||
10. (Optional) Execution of Blender scripts (currently triggered directly, intended to use `utils.blender_utils.py`).
|
7. **Map Merging**: Combine channels from different maps.
|
||||||
|
8. **Metadata Finalization & Save**: Generate and save `metadata.json` (temporarily).
|
||||||
|
9. **Output Organization**: Copy all processed files to final output locations.
|
||||||
|
|
||||||
This architecture allows for a modular design, separating configuration, rule generation/management (GUI, Monitor utilities), and core processing execution. The `SourceRule` object serves as a clear data contract between the rule generation layer and the processing engine. Parallel processing (in Monitor) and background threads (in GUI) are utilized for efficiency and responsiveness.
|
External steps like workspace preparation/cleanup and optional Blender script execution bracket this core pipeline. This architecture allows for a modular design, separating configuration, rule generation/management, and core processing execution.
|
||||||
@ -2,17 +2,65 @@
|
|||||||
|
|
||||||
This document describes the major classes and modules that form the core of the Asset Processor Tool.
|
This document describes the major classes and modules that form the core of the Asset Processor Tool.
|
||||||
|
|
||||||
## `ProcessingEngine` (`processing_engine.py`)
|
## Core Processing Architecture
|
||||||
|
|
||||||
The `ProcessingEngine` class is the new core component responsible for executing the asset processing pipeline for a *single* input asset. Unlike the older `AssetProcessor`, this engine operates *solely* based on a complete `SourceRule` object provided to its `process()` method and the static `Configuration` object passed during initialization. It contains no internal prediction, classification, or fallback logic. Its key responsibilities include:
|
The asset processing pipeline has been refactored into a staged architecture, managed by an orchestrator.
|
||||||
|
|
||||||
* Setting up and cleaning up a temporary workspace for processing (potentially using `utils.workspace_utils`).
|
### `ProcessingEngine` (`processing_engine.py`)
|
||||||
* Extracting or copying input files to the workspace.
|
|
||||||
* Processing files based on the explicit rules and predicted values contained within the input `SourceRule`.
|
The `ProcessingEngine` class serves as the primary entry point for initiating an asset processing task. Its main responsibilities are:
|
||||||
* Processing texture maps (resizing, format/bit depth conversion, inversion, stats calculation) using parameters from the `SourceRule` or static `Configuration`.
|
|
||||||
* Merging channels based on rules defined in the static `Configuration` and parameters from the `SourceRule`.
|
* Initializing a `PipelineOrchestrator` instance.
|
||||||
* Generating the `metadata.json` file containing details about the processed asset, incorporating information from the `SourceRule`.
|
* Providing the `PipelineOrchestrator` with the global `Configuration` object and a predefined list of processing stages.
|
||||||
* Organizing the final output files into the structured library directory.
|
* Invoking the orchestrator's `process_source_rule()` method with the input `SourceRule`, workspace path, output path, and other processing parameters.
|
||||||
|
* Managing a top-level temporary directory for the engine's operations if needed, though individual stages might also use sub-temporary directories via the `AssetProcessingContext`.
|
||||||
|
|
||||||
|
It no longer contains the detailed logic for each processing step (like map manipulation, merging, etc.) directly. Instead, it delegates these tasks to the orchestrator and its stages.
|
||||||
|
|
||||||
|
### `PipelineOrchestrator` (`processing/pipeline/orchestrator.py`)
|
||||||
|
|
||||||
|
The `PipelineOrchestrator` class is responsible for managing the execution of the asset processing pipeline. Its key functions include:
|
||||||
|
|
||||||
|
* Receiving a `SourceRule` object, `Configuration`, and a list of `ProcessingStage` objects.
|
||||||
|
* For each `AssetRule` within the `SourceRule`:
|
||||||
|
* Creating an `AssetProcessingContext` instance.
|
||||||
|
* Sequentially executing each registered `ProcessingStage`, passing the `AssetProcessingContext` to each stage.
|
||||||
|
* Handling exceptions that occur within stages and managing the overall status of asset processing (processed, skipped, failed).
|
||||||
|
* Managing a temporary directory for the duration of a `SourceRule` processing, which is made available to stages via the `AssetProcessingContext`.
|
||||||
|
|
||||||
|
### `AssetProcessingContext` (`processing/pipeline/asset_context.py`)
|
||||||
|
|
||||||
|
The `AssetProcessingContext` is a dataclass that acts as a stateful container for all data related to the processing of a single `AssetRule`. An instance of this context is created by the `PipelineOrchestrator` for each asset and is passed through each processing stage. Key information it holds includes:
|
||||||
|
|
||||||
|
* The input `SourceRule` and the current `AssetRule`.
|
||||||
|
* Paths: `workspace_path`, `engine_temp_dir`, `output_base_path`.
|
||||||
|
* The `Configuration` object.
|
||||||
|
* `effective_supplier`: Determined by an early stage.
|
||||||
|
* `asset_metadata`: A dictionary to accumulate metadata about the asset.
|
||||||
|
* `processed_maps_details`: Stores details about individually processed maps (paths, dimensions, etc.).
|
||||||
|
* `merged_maps_details`: Stores details about merged maps.
|
||||||
|
* `files_to_process`: A list of `FileRule` objects to be processed for the current asset.
|
||||||
|
* `loaded_data_cache`: For caching loaded image data within an asset's processing.
|
||||||
|
* `status_flags`: For signaling conditions like `skip_asset` or `asset_failed`.
|
||||||
|
* `incrementing_value`, `sha5_value`: Optional values for path generation.
|
||||||
|
|
||||||
|
Each stage reads from and writes to this context, allowing data and state to flow through the pipeline.
|
||||||
|
|
||||||
|
### `Processing Stages` (`processing/pipeline/stages/`)
|
||||||
|
|
||||||
|
The actual processing logic is broken down into a series of discrete stages, each inheriting from `ProcessingStage` (`processing/pipeline/stages/base_stage.py`). Each stage implements an `execute(context: AssetProcessingContext)` method. Key stages include (in typical execution order):
|
||||||
|
|
||||||
|
* **`SupplierDeterminationStage`**: Determines the effective supplier.
|
||||||
|
* **`AssetSkipLogicStage`**: Checks if the asset processing should be skipped.
|
||||||
|
* **`MetadataInitializationStage`**: Initializes basic asset metadata.
|
||||||
|
* **`FileRuleFilterStage`**: Filters `FileRule`s to decide which files to process.
|
||||||
|
* **`GlossToRoughConversionStage`**: Handles gloss-to-roughness map inversion.
|
||||||
|
* **`AlphaExtractionToMaskStage`**: Extracts alpha channels to create masks.
|
||||||
|
* **`NormalMapGreenChannelStage`**: Inverts normal map green channels if required.
|
||||||
|
* **`IndividualMapProcessingStage`**: Processes individual maps (POT scaling, resolution variants, color conversion, stats, aspect ratio, filename conventions).
|
||||||
|
* **`MapMergingStage`**: Merges map channels based on rules.
|
||||||
|
* **`MetadataFinalizationAndSaveStage`**: Collects all metadata and saves `metadata.json` to a temporary location.
|
||||||
|
* **`OutputOrganizationStage`**: Copies all processed files and metadata to the final output directory structure.
|
||||||
|
|
||||||
## `Rule Structure` (`rule_structure.py`)
|
## `Rule Structure` (`rule_structure.py`)
|
||||||
|
|
||||||
@ -22,19 +70,19 @@ This module defines the data structures used to represent the hierarchical proce
|
|||||||
* `AssetRule`: A dataclass representing rules applied at the asset level. It contains nested `FileRule` objects.
|
* `AssetRule`: A dataclass representing rules applied at the asset level. It contains nested `FileRule` objects.
|
||||||
* `FileRule`: A dataclass representing rules applied at the file level.
|
* `FileRule`: A dataclass representing rules applied at the file level.
|
||||||
|
|
||||||
These classes hold specific rule parameters (e.g., `supplier_identifier`, `asset_type`, `asset_type_override`, `item_type`, `item_type_override`, `target_asset_name_override`). Attributes like `asset_type` and `item_type_override` now use string types, which are validated against centralized lists in `config/app_settings.json`. These structures support serialization (Pickle, JSON) to allow them to be passed between different parts of the application, including across process boundaries.
|
These classes hold specific rule parameters (e.g., `supplier_identifier`, `asset_type`, `asset_type_override`, `item_type`, `item_type_override`, `target_asset_name_override`, `resolution_override`, `channel_merge_instructions`). Attributes like `asset_type` and `item_type_override` now use string types, which are validated against centralized lists in `config/app_settings.json`. These structures support serialization (Pickle, JSON) to allow them to be passed between different parts of theapplication, including across process boundaries. The `PipelineOrchestrator` and its stages heavily rely on the information within these rule objects, passed via the `AssetProcessingContext`.
|
||||||
|
|
||||||
## `Configuration` (`configuration.py`)
|
## `Configuration` (`configuration.py`)
|
||||||
|
|
||||||
The `Configuration` class manages the tool's settings. It is responsible for:
|
The `Configuration` class manages the tool's settings. It is responsible for:
|
||||||
|
|
||||||
* Loading the core default settings defined in `config/app_settings.json`.
|
* Loading the core default settings defined in `config/app_settings.json` (e.g., `FILE_TYPE_DEFINITIONS`, `ASSET_TYPE_DEFINITIONS`, `image_resolutions`, `map_merge_rules`, `output_filename_pattern`).
|
||||||
* Loading the supplier-specific rules from a selected preset JSON file (`Presets/*.json`).
|
* Loading the supplier-specific rules from a selected preset JSON file (`Presets/*.json`).
|
||||||
* Merging the core settings and preset rules into a single, unified configuration object.
|
* Merging the core settings and preset rules into a single, unified configuration object.
|
||||||
* Validating the loaded configuration to ensure required settings are present.
|
* Validating the loaded configuration to ensure required settings are present.
|
||||||
* Pre-compiling regular expression patterns defined in the preset for efficient file classification by the `PredictionHandler`.
|
* Pre-compiling regular expression patterns defined in the preset for efficient file classification by the prediction handlers.
|
||||||
|
|
||||||
An instance of the `Configuration` class is typically created once per application run (or per processing batch) and passed to the `ProcessingEngine`.
|
An instance of the `Configuration` class is typically created once per application run (or per processing batch) and passed to the `ProcessingEngine`, which then makes it available to the `PipelineOrchestrator` and subsequently to each stage via the `AssetProcessingContext`.
|
||||||
|
|
||||||
## GUI Components (`gui/`)
|
## GUI Components (`gui/`)
|
||||||
|
|
||||||
@ -191,10 +239,10 @@ The `monitor.py` script implements the directory monitoring feature. It has been
|
|||||||
* Loads the necessary `Configuration`.
|
* Loads the necessary `Configuration`.
|
||||||
* Calls `utils.prediction_utils.generate_source_rule_from_archive` to get the `SourceRule`.
|
* Calls `utils.prediction_utils.generate_source_rule_from_archive` to get the `SourceRule`.
|
||||||
* Calls `utils.workspace_utils.prepare_processing_workspace` to set up the workspace.
|
* Calls `utils.workspace_utils.prepare_processing_workspace` to set up the workspace.
|
||||||
* Instantiates and runs the `ProcessingEngine`.
|
* Instantiates and runs the `ProcessingEngine` (which in turn uses the `PipelineOrchestrator`).
|
||||||
* Handles moving the source archive to 'processed' or 'error' directories.
|
* Handles moving the source archive to 'processed' or 'error' directories.
|
||||||
* Cleans up the workspace.
|
* Cleans up the workspace.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
These key components, along with the refactored GUI structure and new utility modules, work together to provide the tool's functionality. The architecture emphasizes separation of concerns (configuration, rule generation, processing, UI), utilizes background processing for responsiveness (GUI prediction, Monitor tasks), and relies on the `SourceRule` object as the central data structure passed between different stages of the workflow.
|
These key components, along with the refactored GUI structure and new utility modules, work together to provide the tool's functionality. The architecture emphasizes separation of concerns (configuration, rule generation, processing, UI), utilizes background processing for responsiveness (GUI prediction, Monitor tasks), and relies on the `SourceRule` object as the central data structure passed between different stages of the workflow. The processing core is now a staged pipeline managed by the `PipelineOrchestrator`, enhancing modularity and maintainability.
|
||||||
@ -1,72 +1,69 @@
|
|||||||
# Developer Guide: Processing Pipeline
|
# Developer Guide: Processing Pipeline
|
||||||
|
|
||||||
This document details the step-by-step technical process executed by the `ProcessingEngine` class (`processing_engine.py`) when processing a single asset. A new instance of `ProcessingEngine` is created for each processing task to ensure state isolation.
|
This document details the step-by-step technical process executed by the asset processing pipeline, which is initiated by the `ProcessingEngine` class (`processing_engine.py`) and orchestrated by the `PipelineOrchestrator` (`processing/pipeline/orchestrator.py`).
|
||||||
|
|
||||||
The `ProcessingEngine.process()` method orchestrates the following pipeline based *solely* on the provided `SourceRule` object and the static `Configuration` object passed during engine initialization. It contains no internal prediction, classification, or fallback logic. All necessary overrides and static configuration values are accessed directly from these inputs.
|
The `ProcessingEngine.process()` method serves as the main entry point. It initializes a `PipelineOrchestrator` instance, providing it with the application's `Configuration` object and a predefined list of processing stages. The `PipelineOrchestrator.process_source_rule()` method then manages the execution of these stages for each asset defined in the input `SourceRule`.
|
||||||
|
|
||||||
The pipeline steps are:
|
A crucial component in this architecture is the `AssetProcessingContext` (`processing/pipeline/asset_context.py`). An instance of this dataclass is created for each `AssetRule` being processed. It acts as a stateful container, carrying all relevant data (source files, rules, configuration, intermediate results, metadata) and is passed sequentially through each stage. Each stage can read from and write to the context, allowing data to flow and be modified throughout the pipeline.
|
||||||
|
|
||||||
1. **Workspace Preparation (External)**:
|
The pipeline stages are executed in the following order:
|
||||||
* Before the `ProcessingEngine` is invoked, the calling code (e.g., `main.ProcessingTask`, `monitor._process_archive_task`) is responsible for setting up a temporary workspace.
|
|
||||||
* This typically involves using `utils.workspace_utils.prepare_processing_workspace`, which creates a temporary directory and extracts the input source (archive or folder) into it.
|
|
||||||
* The path to this prepared workspace is passed to the `ProcessingEngine` during initialization.
|
|
||||||
|
|
||||||
2. **Prediction and Rule Generation (External)**:
|
1. **`SupplierDeterminationStage` (`processing/pipeline/stages/supplier_determination.py`)**:
|
||||||
* Also handled before the `ProcessingEngine` is invoked.
|
* **Responsibility**: Determines the effective supplier for the asset based on the `SourceRule`'s `supplier_identifier`, `supplier_override`, and supplier definitions in the `Configuration`.
|
||||||
* Either the `RuleBasedPredictionHandler`, `LLMPredictionHandler` (triggered by the GUI), or `utils.prediction_utils.generate_source_rule_from_archive` (used by the Monitor) analyzes the input files and generates a `SourceRule` object.
|
* **Context Interaction**: Updates `AssetProcessingContext.effective_supplier` and potentially `AssetProcessingContext.asset_metadata` with supplier information.
|
||||||
* This `SourceRule` contains predicted classifications and initial overrides.
|
|
||||||
* If using the GUI, the user can modify these rules.
|
|
||||||
* The final `SourceRule` object is the primary input to the `ProcessingEngine.process()` method.
|
|
||||||
|
|
||||||
3. **File Inventory (`_inventory_and_classify_files`)**:
|
2. **`AssetSkipLogicStage` (`processing/pipeline/stages/asset_skip_logic.py`)**:
|
||||||
* Scans the contents of the *already prepared* temporary workspace.
|
* **Responsibility**: Checks if the asset should be skipped, typically if the output already exists and overwriting is not forced.
|
||||||
* This step primarily inventories the files present. The *classification* (determining `item_type`, etc.) is taken directly from the input `SourceRule`. The `item_type` for each file (within the `FileRule` objects of the `SourceRule`) is expected to be a key from `Configuration.FILE_TYPE_DEFINITIONS`.
|
* **Context Interaction**: Sets `AssetProcessingContext.status_flags['skip_asset']` to `True` if the asset should be skipped, halting further processing for this asset by the orchestrator.
|
||||||
* Stores the file paths and their associated rules from the `SourceRule` in `self.classified_files`.
|
|
||||||
|
|
||||||
4. **Base Metadata Determination (`_determine_base_metadata`, `_determine_single_asset_metadata`)**:
|
3. **`MetadataInitializationStage` (`processing/pipeline/stages/metadata_initialization.py`)**:
|
||||||
* Determines the base asset name, category, and archetype using the explicit values provided in the input `SourceRule` and the static `Configuration`. Overrides (like `supplier_identifier`, `asset_type`, `asset_name_override`) are taken directly from the `SourceRule`. The `asset_type` (within the `AssetRule` object of the `SourceRule`) is expected to be a key from `Configuration.ASSET_TYPE_DEFINITIONS`.
|
* **Responsibility**: Initializes the `AssetProcessingContext.asset_metadata` dictionary with base information derived from the `AssetRule`, `SourceRule`, and `Configuration`. This includes asset name, type, and any common metadata.
|
||||||
|
* **Context Interaction**: Populates `AssetProcessingContext.asset_metadata`.
|
||||||
|
|
||||||
5. **Skip Check**:
|
4. **`FileRuleFilterStage` (`processing/pipeline/stages/file_rule_filter.py`)**:
|
||||||
* If the `overwrite` flag is `False`, checks if the final output directory already exists and contains `metadata.json`.
|
* **Responsibility**: Filters the `FileRule` objects from the `AssetRule` to determine which files should actually be processed. It respects `FILE_IGNORE` rules.
|
||||||
* If so, processing for this asset is skipped.
|
* **Context Interaction**: Populates `AssetProcessingContext.files_to_process` with the list of `FileRule` objects that passed the filter.
|
||||||
|
|
||||||
6. **Map Processing (`_process_maps`)**:
|
5. **`GlossToRoughConversionStage` (`processing/pipeline/stages/gloss_to_rough_conversion.py`)**:
|
||||||
* Iterates through files classified as maps in the `SourceRule`.
|
* **Responsibility**: Identifies gloss maps (based on `FileRule` properties and filename conventions) that are intended to be used as roughness maps. If found, it loads the image, inverts its colors, and saves a temporary inverted version.
|
||||||
* Loads images (`cv2.imread`).
|
* **Context Interaction**: Modifies `FileRule` objects in `AssetProcessingContext.files_to_process` (e.g., updates `file_path` to point to the temporary inverted map, sets flags indicating inversion). Updates `AssetProcessingContext.processed_maps_details` with information about the conversion.
|
||||||
* **Glossiness-to-Roughness Inversion**:
|
|
||||||
* The system identifies a map as a gloss map if its input filename contains "MAP_GLOSS" (case-insensitive) and is intended to become a roughness map (e.g., its `item_type` or `item_type_override` in the `SourceRule` effectively designates it as roughness).
|
|
||||||
* If these conditions are met, its colors are inverted.
|
|
||||||
* After inversion, the map is treated as a "MAP_ROUGH" type for subsequent processing steps.
|
|
||||||
* The fact that a map was derived from a gloss source and inverted is recorded in the output `metadata.json` for that map type using the `derived_from_gloss_filename: true` flag. This replaces the previous reliance on an internal `is_gloss_source` flag within the `FileRule` structure.
|
|
||||||
* Resizes images based on `Configuration`.
|
|
||||||
* Determines output bit depth and format based on `Configuration` and `SourceRule`.
|
|
||||||
* Converts data types and saves images (`cv2.imwrite`).
|
|
||||||
* The output filename uses the `standard_type` alias (e.g., `COL`, `NRM`) retrieved from the `Configuration.FILE_TYPE_DEFINITIONS` based on the file's effective `item_type`.
|
|
||||||
* Calculates image statistics.
|
|
||||||
* Stores processed map details.
|
|
||||||
|
|
||||||
7. **Map Merging (`_merge_maps_from_source`)**:
|
6. **`AlphaExtractionToMaskStage` (`processing/pipeline/stages/alpha_extraction_to_mask.py`)**:
|
||||||
* Iterates through `MAP_MERGE_RULES` in `Configuration`.
|
* **Responsibility**: If a `FileRule` specifies alpha channel extraction (e.g., from a diffuse map to create an opacity mask), this stage loads the source image, extracts its alpha channel, and saves it as a new temporary grayscale map.
|
||||||
* Identifies required source maps by checking the `item_type_override` within the `SourceRule` (specifically in the `FileRule` for each file). Both `item_type` and `item_type_override` are expected to be keys from `Configuration.FILE_TYPE_DEFINITIONS`. Files with a base `item_type` of `"FILE_IGNORE"` are explicitly excluded from consideration.
|
* **Context Interaction**: May add new `FileRule`-like entries or details to `AssetProcessingContext.processed_maps_details` representing the extracted mask.
|
||||||
* Loads source channels, handling missing inputs with defaults from `Configuration` or `SourceRule`.
|
|
||||||
* Merges channels (`cv2.merge`).
|
|
||||||
* Determines output format/bit depth and saves the merged map.
|
|
||||||
* Stores merged map details.
|
|
||||||
|
|
||||||
8. **Metadata File Generation (`_generate_metadata_file`)**:
|
7. **`NormalMapGreenChannelStage` (`processing/pipeline/stages/normal_map_green_channel.py`)**:
|
||||||
* Collects asset metadata, processed/merged map details, ignored files list, etc., primarily from the `SourceRule` and internal processing results.
|
* **Responsibility**: Checks `FileRule`s for normal maps and, based on configuration (e.g., `invert_normal_map_green_channel` for a specific supplier), potentially inverts the green channel of the normal map image.
|
||||||
* Writes data to `metadata.json` in the temporary workspace.
|
* **Context Interaction**: Modifies the image data for normal maps if inversion is needed, saving a new temporary version. Updates `AssetProcessingContext.processed_maps_details`.
|
||||||
|
|
||||||
9. **Output Organization (`_organize_output_files`)**:
|
8. **`IndividualMapProcessingStage` (`processing/pipeline/stages/individual_map_processing.py`)**:
|
||||||
* Determines the final output directory using the global `OUTPUT_DIRECTORY_PATTERN` and the final filename using the global `OUTPUT_FILENAME_PATTERN` (both from the `Configuration` object). The `utils.path_utils` module combines these with the base output directory and asset-specific data (like asset name, map type, resolution, etc.) to construct the full path for each file.
|
* **Responsibility**: Processes individual texture map files. This includes:
|
||||||
* Creates the final structured output directory (`<output_base_dir>/<supplier_name>/<asset_name>/`), using the supplier name from the `SourceRule`.
|
* Loading the source image.
|
||||||
* Moves processed maps, merged maps, models, metadata, and other classified files from the temporary workspace to the final output directory.
|
* Applying Power-of-Two (POT) scaling.
|
||||||
|
* Generating multiple resolution variants based on configuration.
|
||||||
|
* Handling color space conversions (e.g., BGR to RGB).
|
||||||
|
* Calculating image statistics (min, max, mean, median).
|
||||||
|
* Determining and storing aspect ratio change information.
|
||||||
|
* Saving processed temporary map files.
|
||||||
|
* Applying name variant suffixing and using standard type aliases for filenames.
|
||||||
|
* **Context Interaction**: Heavily populates `AssetProcessingContext.processed_maps_details` with paths to temporary processed files, dimensions, and other metadata for each map and its variants. Updates `AssetProcessingContext.asset_metadata` with image stats and aspect ratio info.
|
||||||
|
|
||||||
10. **Workspace Cleanup (External)**:
|
9. **`MapMergingStage` (`processing/pipeline/stages/map_merging.py`)**:
|
||||||
* After the `ProcessingEngine.process()` method completes (successfully or with errors), the *calling code* is responsible for cleaning up the temporary workspace directory created in Step 1. This is often done in a `finally` block where `utils.workspace_utils.prepare_processing_workspace` was called.
|
* **Responsibility**: Performs channel packing and other merge operations based on `map_merge_rules` defined in the `Configuration`.
|
||||||
|
* **Context Interaction**: Reads source map details and temporary file paths from `AssetProcessingContext.processed_maps_details`. Saves new temporary merged maps and records their details in `AssetProcessingContext.merged_maps_details`.
|
||||||
|
|
||||||
11. **(Optional) Blender Script Execution (External)**:
|
10. **`MetadataFinalizationAndSaveStage` (`processing/pipeline/stages/metadata_finalization_save.py`)**:
|
||||||
* If triggered (e.g., via CLI arguments or GUI controls), the orchestrating code (e.g., `main.ProcessingTask`) executes the corresponding Blender scripts (`blenderscripts/*.py`) using `subprocess.run` *after* the `ProcessingEngine.process()` call completes successfully.
|
* **Responsibility**: Collects all accumulated metadata from `AssetProcessingContext.asset_metadata`, `AssetProcessingContext.processed_maps_details`, and `AssetProcessingContext.merged_maps_details`. It structures this information and saves it as the `metadata.json` file in a temporary location within the engine's temporary directory.
|
||||||
* *Note: Centralized logic for this was intended for `utils/blender_utils.py`, but this utility has not yet been implemented.* See `Developer Guide: Blender Integration Internals` for more details.
|
* **Context Interaction**: Reads from various context fields and writes the `metadata.json` file. Stores the path to this temporary metadata file in the context (e.g., `AssetProcessingContext.asset_metadata['temp_metadata_path']`).
|
||||||
|
|
||||||
This pipeline, executed by the `ProcessingEngine`, provides a clear and explicit processing flow based on the complete rule set provided by the GUI or other interfaces.
|
11. **`OutputOrganizationStage` (`processing/pipeline/stages/output_organization.py`)**:
|
||||||
|
* **Responsibility**: Determines final output paths for all processed maps, merged maps, the metadata file, and any other asset files (like models). It then copies these files from their temporary locations to the final structured output directory.
|
||||||
|
* **Context Interaction**: Reads temporary file paths from `AssetProcessingContext.processed_maps_details`, `AssetProcessingContext.merged_maps_details`, and the temporary metadata file path. Uses `Configuration` for output path patterns. Updates `AssetProcessingContext.asset_metadata` with final file paths and status.
|
||||||
|
|
||||||
|
**External Steps (Not part of `PipelineOrchestrator`'s direct loop but integral to the overall process):**
|
||||||
|
|
||||||
|
* **Workspace Preparation and Cleanup**: Handled by the code that invokes `ProcessingEngine.process()` (e.g., `main.ProcessingTask`, `monitor._process_archive_task`), typically using `utils.workspace_utils`. The engine itself creates a sub-temporary directory (`engine_temp_dir`) within the workspace provided to it by the orchestrator, which it cleans up.
|
||||||
|
* **Prediction and Rule Generation**: Also external, performed before `ProcessingEngine` is called. Generates the `SourceRule`.
|
||||||
|
* **Optional Blender Script Execution**: Triggered externally after successful processing.
|
||||||
|
|
||||||
|
This staged pipeline provides a modular and extensible architecture for asset processing, with clear separation of concerns for each step. The `AssetProcessingContext` ensures that data flows consistently between these stages.r
|
||||||
@ -56,7 +56,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"target_type": "MAP_ROUGH",
|
"target_type": "MAP_GLOSS",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"GLOSS"
|
"GLOSS"
|
||||||
]
|
]
|
||||||
|
|||||||
@ -54,7 +54,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"target_type": "MAP_ROUGH",
|
"target_type": "MAP_GLOSS",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"GLOSS"
|
"GLOSS"
|
||||||
],
|
],
|
||||||
|
|||||||
@ -246,7 +246,7 @@
|
|||||||
],
|
],
|
||||||
"EXTRA_FILES_SUBDIR": "Extra",
|
"EXTRA_FILES_SUBDIR": "Extra",
|
||||||
"OUTPUT_BASE_DIR": "../Asset_Processor_Output_Tests",
|
"OUTPUT_BASE_DIR": "../Asset_Processor_Output_Tests",
|
||||||
"OUTPUT_DIRECTORY_PATTERN": "[supplier]/[sha5]_[assetname]",
|
"OUTPUT_DIRECTORY_PATTERN": "[supplier]_[assetname]",
|
||||||
"OUTPUT_FILENAME_PATTERN": "[assetname]_[maptype]_[resolution].[ext]",
|
"OUTPUT_FILENAME_PATTERN": "[assetname]_[maptype]_[resolution].[ext]",
|
||||||
"METADATA_FILENAME": "metadata.json",
|
"METADATA_FILENAME": "metadata.json",
|
||||||
"DEFAULT_NODEGROUP_BLEND_PATH": "G:/02 Content/10-19 Content/19 Catalogs/19.01 Blender Asset Catalogue/_CustomLibraries/Nodes-Linked/PBRSET-Nodes-Testing.blend",
|
"DEFAULT_NODEGROUP_BLEND_PATH": "G:/02 Content/10-19 Content/19 Catalogs/19.01 Blender Asset Catalogue/_CustomLibraries/Nodes-Linked/PBRSET-Nodes-Testing.blend",
|
||||||
@ -259,7 +259,8 @@
|
|||||||
"8K": 8192,
|
"8K": 8192,
|
||||||
"4K": 4096,
|
"4K": 4096,
|
||||||
"2K": 2048,
|
"2K": 2048,
|
||||||
"1K": 1024
|
"1K": 1024,
|
||||||
|
"PREVIEW": 128
|
||||||
},
|
},
|
||||||
"ASPECT_RATIO_DECIMALS": 2,
|
"ASPECT_RATIO_DECIMALS": 2,
|
||||||
"OUTPUT_FORMAT_16BIT_PRIMARY": "exr",
|
"OUTPUT_FORMAT_16BIT_PRIMARY": "exr",
|
||||||
@ -269,9 +270,9 @@
|
|||||||
{
|
{
|
||||||
"output_map_type": "NRMRGH",
|
"output_map_type": "NRMRGH",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"R": "NRM",
|
"R": "MAP_NRM",
|
||||||
"G": "NRM",
|
"G": "MAP_NRM",
|
||||||
"B": "ROUGH"
|
"B": "MAP_ROUGH"
|
||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"R": 0.5,
|
"R": 0.5,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import uuid
|
import uuid
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import re
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -63,17 +64,136 @@ class IndividualMapProcessingStage(ProcessingStage):
|
|||||||
self._update_file_rule_status(context, temp_id_for_fail, 'Failed', map_type=map_type_for_fail, details="Workspace path invalid")
|
self._update_file_rule_status(context, temp_id_for_fail, 'Failed', map_type=map_type_for_fail, details="Workspace path invalid")
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
# Fetch config settings once before the loop
|
||||||
|
respect_variant_map_types = getattr(context.config_obj, "respect_variant_map_types", [])
|
||||||
|
image_resolutions = getattr(context.config_obj, "image_resolutions", {})
|
||||||
|
output_filename_pattern = getattr(context.config_obj, "output_filename_pattern", "[assetname]_[maptype]_[resolution].[ext]")
|
||||||
|
|
||||||
for file_rule_idx, file_rule in enumerate(context.files_to_process):
|
for file_rule_idx, file_rule in enumerate(context.files_to_process):
|
||||||
# Generate a unique ID for this file_rule processing instance for processed_maps_details
|
# Generate a unique ID for this file_rule processing instance for processed_maps_details
|
||||||
current_map_id_hex = f"map_{file_rule_idx}_{uuid.uuid4().hex[:8]}"
|
current_map_id_hex = f"map_{file_rule_idx}_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
current_map_type = file_rule.item_type_override or file_rule.item_type or "UnknownMapType"
|
initial_current_map_type = file_rule.item_type_override or file_rule.item_type or "UnknownMapType"
|
||||||
|
|
||||||
|
# --- START NEW SUFFIXING LOGIC ---
|
||||||
|
final_current_map_type = initial_current_map_type # Default to initial
|
||||||
|
|
||||||
|
# 1. Determine Base Map Type from initial_current_map_type
|
||||||
|
base_map_type_match = re.match(r"(MAP_[A-Z]{3})", initial_current_map_type)
|
||||||
|
|
||||||
|
if base_map_type_match and context.asset_rule:
|
||||||
|
true_base_map_type = base_map_type_match.group(1) # This is "MAP_XXX"
|
||||||
|
|
||||||
|
# 2. Count Occurrences and Find Index of current_file_rule in context.asset_rule.files
|
||||||
|
peers_of_same_base_type_in_asset_rule = []
|
||||||
|
for fr_asset in context.asset_rule.files:
|
||||||
|
fr_asset_item_type = fr_asset.item_type_override or fr_asset.item_type or "UnknownMapType"
|
||||||
|
fr_asset_base_map_type_match = re.match(r"(MAP_[A-Z]{3})", fr_asset_item_type)
|
||||||
|
|
||||||
|
if fr_asset_base_map_type_match:
|
||||||
|
fr_asset_base_map_type = fr_asset_base_map_type_match.group(1)
|
||||||
|
if fr_asset_base_map_type == true_base_map_type:
|
||||||
|
peers_of_same_base_type_in_asset_rule.append(fr_asset)
|
||||||
|
|
||||||
|
num_occurrences_of_base_type = len(peers_of_same_base_type_in_asset_rule)
|
||||||
|
current_instance_index = 0 # 1-based
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_instance_index = peers_of_same_base_type_in_asset_rule.index(file_rule) + 1
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (Initial Type: '{initial_current_map_type}', Base: '{true_base_map_type}'): "
|
||||||
|
f"Could not find its own instance in the list of peers from asset_rule.files. "
|
||||||
|
f"Number of peers found: {num_occurrences_of_base_type}. Suffixing may be affected."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Determine Suffix
|
||||||
|
map_type_for_respect_check = true_base_map_type.replace("MAP_", "") # e.g., "COL"
|
||||||
|
is_in_respect_list = map_type_for_respect_check in respect_variant_map_types
|
||||||
|
|
||||||
|
suffix_to_append = ""
|
||||||
|
if num_occurrences_of_base_type > 1:
|
||||||
|
if current_instance_index > 0:
|
||||||
|
suffix_to_append = f"-{current_instance_index}"
|
||||||
|
else:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}': Index for multi-occurrence map type '{true_base_map_type}' (count: {num_occurrences_of_base_type}) not determined. Omitting numeric suffix.")
|
||||||
|
elif num_occurrences_of_base_type == 1 and is_in_respect_list:
|
||||||
|
suffix_to_append = "-1"
|
||||||
|
|
||||||
|
# 4. Form the final_current_map_type
|
||||||
|
if suffix_to_append:
|
||||||
|
final_current_map_type = true_base_map_type + suffix_to_append
|
||||||
|
else:
|
||||||
|
final_current_map_type = initial_current_map_type
|
||||||
|
|
||||||
|
current_map_type = final_current_map_type
|
||||||
|
# --- END NEW SUFFIXING LOGIC ---
|
||||||
|
|
||||||
|
# --- START: Filename-friendly map type derivation ---
|
||||||
|
logger.debug(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: --- Starting Filename-Friendly Map Type Logic for: {current_map_type} ---")
|
||||||
|
filename_friendly_map_type = current_map_type # Fallback
|
||||||
|
|
||||||
|
# 1. Access FILE_TYPE_DEFINITIONS
|
||||||
|
file_type_definitions = None
|
||||||
|
logger.debug(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Attempting to access context.config_obj.FILE_TYPE_DEFINITIONS.")
|
||||||
|
try:
|
||||||
|
file_type_definitions = context.config_obj.FILE_TYPE_DEFINITIONS
|
||||||
|
if not file_type_definitions: # Check if it's None or empty
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: FILE_TYPE_DEFINITIONS is present but empty or None.")
|
||||||
|
else:
|
||||||
|
sample_defs_log = {k: file_type_definitions[k] for k in list(file_type_definitions.keys())[:2]} # Log first 2 for brevity
|
||||||
|
logger.debug(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Accessed FILE_TYPE_DEFINITIONS. Sample: {sample_defs_log}, Total keys: {len(file_type_definitions)}.")
|
||||||
|
except AttributeError:
|
||||||
|
logger.error(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Could not access context.config_obj.FILE_TYPE_DEFINITIONS via direct attribute.")
|
||||||
|
|
||||||
|
base_map_key = None
|
||||||
|
suffix_part = ""
|
||||||
|
|
||||||
|
if file_type_definitions and isinstance(file_type_definitions, dict) and len(file_type_definitions) > 0:
|
||||||
|
base_map_key = None
|
||||||
|
suffix_part = ""
|
||||||
|
|
||||||
|
sorted_known_base_keys = sorted(list(file_type_definitions.keys()), key=len, reverse=True)
|
||||||
|
logger.debug(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Sorted known base keys for parsing: {sorted_known_base_keys}")
|
||||||
|
|
||||||
|
for known_key in sorted_known_base_keys:
|
||||||
|
logger.debug(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Checking if '{current_map_type}' starts with '{known_key}'")
|
||||||
|
if current_map_type.startswith(known_key):
|
||||||
|
base_map_key = known_key
|
||||||
|
suffix_part = current_map_type[len(known_key):]
|
||||||
|
logger.debug(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Match found! current_map_type: '{current_map_type}', base_map_key: '{base_map_key}', suffix_part: '{suffix_part}'")
|
||||||
|
break
|
||||||
|
|
||||||
|
if base_map_key is None:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Could not parse base_map_key from '{current_map_type}' using known keys. Fallback: filename_friendly_map_type = '{filename_friendly_map_type}'.")
|
||||||
|
else:
|
||||||
|
definition = file_type_definitions.get(base_map_key)
|
||||||
|
logger.debug(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Definition for '{base_map_key}': {definition}")
|
||||||
|
if definition and isinstance(definition, dict):
|
||||||
|
standard_type_alias = definition.get("standard_type")
|
||||||
|
logger.debug(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Standard type alias for '{base_map_key}': '{standard_type_alias}'")
|
||||||
|
if standard_type_alias and isinstance(standard_type_alias, str) and standard_type_alias.strip():
|
||||||
|
filename_friendly_map_type = standard_type_alias.strip() + suffix_part
|
||||||
|
logger.info(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Successfully transformed map type: '{current_map_type}' -> '{filename_friendly_map_type}' (standard_type_alias: '{standard_type_alias}', suffix_part: '{suffix_part}').")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Standard type alias for '{base_map_key}' is missing, empty, or not a string (value: '{standard_type_alias}'). Using fallback. filename_friendly_map_type = '{filename_friendly_map_type}'.")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: No definition or invalid definition for '{base_map_key}' (value: {definition}). Using fallback. filename_friendly_map_type = '{filename_friendly_map_type}'.")
|
||||||
|
elif file_type_definitions is None:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: FILE_TYPE_DEFINITIONS not available for lookup (was None). Using fallback. filename_friendly_map_type = '{filename_friendly_map_type}'.")
|
||||||
|
elif not isinstance(file_type_definitions, dict):
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: FILE_TYPE_DEFINITIONS is not a dictionary (type: {type(file_type_definitions)}). Using fallback. filename_friendly_map_type = '{filename_friendly_map_type}'.")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: FILE_TYPE_DEFINITIONS is an empty dictionary. Using fallback. filename_friendly_map_type = '{filename_friendly_map_type}'.")
|
||||||
|
|
||||||
|
logger.debug(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Final filename_friendly_map_type: '{filename_friendly_map_type}'")
|
||||||
|
# --- END: Filename-friendly map type derivation ---
|
||||||
|
|
||||||
if not current_map_type or not current_map_type.startswith("MAP_") or current_map_type == "MAP_GEN_COMPOSITE":
|
if not current_map_type or not current_map_type.startswith("MAP_") or current_map_type == "MAP_GEN_COMPOSITE":
|
||||||
logger.debug(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}': Skipping, item_type '{current_map_type}' not targeted for individual processing.")
|
logger.debug(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}': Skipping, item_type '{current_map_type}' (initial: '{initial_current_map_type}') not targeted for individual processing.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (Type: {current_map_type}, ID: {current_map_id_hex}): Starting individual processing.")
|
logger.info(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (Type: {current_map_type}, Initial Type: {initial_current_map_type}, ID: {current_map_id_hex}): Starting individual processing.")
|
||||||
|
|
||||||
# A. Find Source File (using file_rule.file_path as the pattern relative to source_base_path)
|
# A. Find Source File (using file_rule.file_path as the pattern relative to source_base_path)
|
||||||
# The _find_source_file might need adjustment if file_rule.file_path is absolute or needs complex globbing.
|
# The _find_source_file might need adjustment if file_rule.file_path is absolute or needs complex globbing.
|
||||||
@ -81,117 +201,343 @@ class IndividualMapProcessingStage(ProcessingStage):
|
|||||||
source_file_path = self._find_source_file(source_base_path, file_rule.file_path, asset_name_for_log, current_map_id_hex)
|
source_file_path = self._find_source_file(source_base_path, file_rule.file_path, asset_name_for_log, current_map_id_hex)
|
||||||
if not source_file_path:
|
if not source_file_path:
|
||||||
logger.error(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Source file not found with path/pattern '{file_rule.file_path}' in '{source_base_path}'.")
|
logger.error(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Source file not found with path/pattern '{file_rule.file_path}' in '{source_base_path}'.")
|
||||||
self._update_file_rule_status(context, current_map_id_hex, 'Failed', map_type=current_map_type, details="Source file not found")
|
self._update_file_rule_status(context, current_map_id_hex, 'Failed', map_type=filename_friendly_map_type, details="Source file not found")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# B. Load and Transform Image
|
# B. Load and Transform Image
|
||||||
image_data: Optional[np.ndarray] = ipu.load_image(str(source_file_path))
|
image_data: Optional[np.ndarray] = ipu.load_image(str(source_file_path))
|
||||||
if image_data is None:
|
if image_data is None:
|
||||||
logger.error(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Failed to load image from '{source_file_path}'.")
|
logger.error(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Failed to load image from '{source_file_path}'.")
|
||||||
self._update_file_rule_status(context, current_map_id_hex, 'Failed', map_type=current_map_type, source_file=str(source_file_path), details="Image load failed")
|
self._update_file_rule_status(context, current_map_id_hex, 'Failed', map_type=filename_friendly_map_type, source_file=str(source_file_path), details="Image load failed")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
original_height, original_width = image_data.shape[:2]
|
original_height, original_width = image_data.shape[:2]
|
||||||
logger.debug(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Loaded image '{source_file_path}' with dimensions {original_width}x{original_height}.")
|
logger.debug(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Loaded image '{source_file_path}' with dimensions {original_width}x{original_height}.")
|
||||||
|
|
||||||
# Initialize transform settings with defaults
|
# 1. Initial Power-of-Two (POT) Downscaling
|
||||||
transform_settings = {
|
pot_width = ipu.get_nearest_power_of_two_downscale(original_width)
|
||||||
"target_width": 2048,
|
pot_height = ipu.get_nearest_power_of_two_downscale(original_height)
|
||||||
"target_height": None,
|
|
||||||
"resize_mode": "fit",
|
|
||||||
"ensure_pot": False,
|
|
||||||
"allow_upscale": False,
|
|
||||||
"resize_filter": "AREA",
|
|
||||||
"color_profile_management": False,
|
|
||||||
"target_color_profile": "sRGB",
|
|
||||||
"output_format_settings": None
|
|
||||||
}
|
|
||||||
|
|
||||||
# Attempt to load transform settings from file_rule.channel_merge_instructions
|
# Maintain aspect ratio for initial POT scaling, using the smaller of the scaled dimensions
|
||||||
|
# This ensures we only downscale.
|
||||||
|
if original_width > 0 and original_height > 0 : # Avoid division by zero
|
||||||
|
aspect_ratio = original_width / original_height
|
||||||
|
|
||||||
|
# Calculate new dimensions based on POT width, then POT height, and pick the one that results in downscale or same size
|
||||||
|
pot_h_from_w = int(pot_width / aspect_ratio)
|
||||||
|
pot_w_from_h = int(pot_height * aspect_ratio)
|
||||||
|
|
||||||
|
# Option 1: Scale by width, adjust height
|
||||||
|
candidate1_w, candidate1_h = pot_width, ipu.get_nearest_power_of_two_downscale(pot_h_from_w)
|
||||||
|
# Option 2: Scale by height, adjust width
|
||||||
|
candidate2_w, candidate2_h = ipu.get_nearest_power_of_two_downscale(pot_w_from_h), pot_height
|
||||||
|
|
||||||
|
# Ensure candidates are not upscaling
|
||||||
|
if candidate1_w > original_width or candidate1_h > original_height:
|
||||||
|
candidate1_w, candidate1_h = original_width, original_height # Fallback to original if upscaling
|
||||||
|
if candidate2_w > original_width or candidate2_h > original_height:
|
||||||
|
candidate2_w, candidate2_h = original_width, original_height # Fallback to original if upscaling
|
||||||
|
|
||||||
|
# Choose the candidate that results in a larger area (preferring less downscaling if multiple POT options)
|
||||||
|
# but still respects the POT downscale logic for each dimension individually.
|
||||||
|
# The actual POT dimensions are already calculated by get_nearest_power_of_two_downscale.
|
||||||
|
# We need to decide if we base the aspect ratio calc on pot_width or pot_height.
|
||||||
|
# The goal is to make one dimension POT and the other POT while maintaining aspect as much as possible, only downscaling.
|
||||||
|
|
||||||
|
final_pot_width = ipu.get_nearest_power_of_two_downscale(original_width)
|
||||||
|
final_pot_height = ipu.get_nearest_power_of_two_downscale(original_height)
|
||||||
|
|
||||||
|
# If original aspect is not 1:1, one of the POT dimensions might need further adjustment to maintain aspect
|
||||||
|
# after the other dimension is set to its POT.
|
||||||
|
# We prioritize fitting within the *downscaled* POT dimensions.
|
||||||
|
|
||||||
|
# Scale to fit within final_pot_width, adjust height, then make height POT (downscale)
|
||||||
|
scaled_h_for_pot_w = max(1, round(final_pot_width / aspect_ratio))
|
||||||
|
h1 = ipu.get_nearest_power_of_two_downscale(scaled_h_for_pot_w)
|
||||||
|
w1 = final_pot_width
|
||||||
|
if h1 > final_pot_height: # If this adjustment made height too big, re-evaluate
|
||||||
|
h1 = final_pot_height
|
||||||
|
w1 = ipu.get_nearest_power_of_two_downscale(max(1, round(h1 * aspect_ratio)))
|
||||||
|
|
||||||
|
|
||||||
|
# Scale to fit within final_pot_height, adjust width, then make width POT (downscale)
|
||||||
|
scaled_w_for_pot_h = max(1, round(final_pot_height * aspect_ratio))
|
||||||
|
w2 = ipu.get_nearest_power_of_two_downscale(scaled_w_for_pot_h)
|
||||||
|
h2 = final_pot_height
|
||||||
|
if w2 > final_pot_width: # If this adjustment made width too big, re-evaluate
|
||||||
|
w2 = final_pot_width
|
||||||
|
h2 = ipu.get_nearest_power_of_two_downscale(max(1, round(w2 / aspect_ratio)))
|
||||||
|
|
||||||
|
# Choose the option that results in larger area (less aggressive downscaling)
|
||||||
|
# while ensuring both dimensions are POT and not upscaled from original.
|
||||||
|
if w1 * h1 >= w2 * h2:
|
||||||
|
base_pot_width, base_pot_height = w1, h1
|
||||||
|
else:
|
||||||
|
base_pot_width, base_pot_height = w2, h2
|
||||||
|
|
||||||
|
# Final check to ensure no upscaling from original dimensions
|
||||||
|
base_pot_width = min(base_pot_width, original_width)
|
||||||
|
base_pot_height = min(base_pot_height, original_height)
|
||||||
|
# And ensure they are POT
|
||||||
|
base_pot_width = ipu.get_nearest_power_of_two_downscale(base_pot_width)
|
||||||
|
base_pot_height = ipu.get_nearest_power_of_two_downscale(base_pot_height)
|
||||||
|
|
||||||
|
else: # Handle cases like 0-dim images, though load_image should prevent this
|
||||||
|
base_pot_width, base_pot_height = 1, 1
|
||||||
|
|
||||||
|
|
||||||
|
logger.info(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Original dims: ({original_width},{original_height}), Initial POT Scaled Dims: ({base_pot_width},{base_pot_height}).")
|
||||||
|
|
||||||
|
# Calculate and store aspect ratio change string
|
||||||
|
if original_width > 0 and original_height > 0 and base_pot_width > 0 and base_pot_height > 0:
|
||||||
|
aspect_change_str = ipu.normalize_aspect_ratio_change(
|
||||||
|
original_width, original_height,
|
||||||
|
base_pot_width, base_pot_height
|
||||||
|
)
|
||||||
|
if aspect_change_str:
|
||||||
|
# This will overwrite if multiple maps are processed; specified by requirements.
|
||||||
|
context.asset_metadata['aspect_ratio_change_string'] = aspect_change_str
|
||||||
|
logger.info(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}, Map Type {current_map_type}: Calculated aspect ratio change string: '{aspect_change_str}' (Original: {original_width}x{original_height}, Base POT: {base_pot_width}x{base_pot_height}). Stored in asset_metadata.")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}, Map Type {current_map_type}: Failed to calculate aspect ratio change string.")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}, Map Type {current_map_type}: Skipping aspect ratio change string calculation due to invalid dimensions (Original: {original_width}x{original_height}, Base POT: {base_pot_width}x{base_pot_height}).")
|
||||||
|
|
||||||
|
base_pot_image_data = image_data.copy()
|
||||||
|
if (base_pot_width, base_pot_height) != (original_width, original_height):
|
||||||
|
interpolation = cv2.INTER_AREA # Good for downscaling
|
||||||
|
base_pot_image_data = ipu.resize_image(base_pot_image_data, base_pot_width, base_pot_height, interpolation=interpolation)
|
||||||
|
if base_pot_image_data is None:
|
||||||
|
logger.error(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Failed to resize image to base POT dimensions.")
|
||||||
|
self._update_file_rule_status(context, current_map_id_hex, 'Failed', map_type=filename_friendly_map_type, source_file=str(source_file_path), original_dimensions=(original_width, original_height), details="Base POT resize failed")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Color Profile Management (after initial POT resize, before multi-res saving)
|
||||||
|
# Initialize transform settings with defaults for color management
|
||||||
|
transform_settings = {
|
||||||
|
"color_profile_management": False, # Default, can be overridden by FileRule
|
||||||
|
"target_color_profile": "sRGB", # Default
|
||||||
|
"output_format_settings": None # For JPG quality, PNG compression
|
||||||
|
}
|
||||||
if file_rule.channel_merge_instructions and 'transform' in file_rule.channel_merge_instructions:
|
if file_rule.channel_merge_instructions and 'transform' in file_rule.channel_merge_instructions:
|
||||||
custom_transform_settings = file_rule.channel_merge_instructions['transform']
|
custom_transform_settings = file_rule.channel_merge_instructions['transform']
|
||||||
if isinstance(custom_transform_settings, dict):
|
if isinstance(custom_transform_settings, dict):
|
||||||
transform_settings.update(custom_transform_settings)
|
transform_settings.update(custom_transform_settings)
|
||||||
logger.info(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Loaded transform settings from file_rule.channel_merge_instructions.")
|
logger.info(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Loaded transform settings for color/output from file_rule.")
|
||||||
else:
|
|
||||||
logger.warning(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): 'transform' in channel_merge_instructions is not a dictionary. Using defaults.")
|
|
||||||
# TODO: Implement fallback to context.config_obj for global/item_type specific transform settings
|
|
||||||
# else:
|
|
||||||
# # Example: config_transforms = context.config_obj.get_transform_settings(file_rule.item_type or file_rule.item_type_override)
|
|
||||||
# # if config_transforms:
|
|
||||||
# # transform_settings.update(config_transforms)
|
|
||||||
|
|
||||||
target_width, target_height = ipu.calculate_target_dimensions(
|
|
||||||
original_width, original_height,
|
|
||||||
transform_settings['target_width'], transform_settings['target_height'],
|
|
||||||
transform_settings['resize_mode'],
|
|
||||||
transform_settings['ensure_pot'],
|
|
||||||
transform_settings['allow_upscale']
|
|
||||||
)
|
|
||||||
logger.debug(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Original dims: ({original_width},{original_height}), Calculated target dims: ({target_width},{target_height}) using sourced transforms.")
|
|
||||||
|
|
||||||
processed_image_data = image_data.copy()
|
|
||||||
|
|
||||||
if (target_width, target_height) != (original_width, original_height):
|
|
||||||
logger.info(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Resizing from ({original_width},{original_height}) to ({target_width},{target_height}).")
|
|
||||||
interpolation_map = {"NEAREST": cv2.INTER_NEAREST, "LINEAR": cv2.INTER_LINEAR, "CUBIC": cv2.INTER_CUBIC, "AREA": cv2.INTER_AREA, "LANCZOS4": cv2.INTER_LANCZOS4}
|
|
||||||
interpolation = interpolation_map.get(transform_settings['resize_filter'].upper(), cv2.INTER_AREA)
|
|
||||||
processed_image_data = ipu.resize_image(processed_image_data, target_width, target_height, interpolation=interpolation)
|
|
||||||
if processed_image_data is None:
|
|
||||||
logger.error(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Failed to resize image.")
|
|
||||||
self._update_file_rule_status(context, current_map_id_hex, 'Failed', map_type=current_map_type, source_file=str(source_file_path), original_dimensions=(original_width, original_height), details="Image resize failed")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if transform_settings['color_profile_management'] and transform_settings['target_color_profile'] == "RGB":
|
if transform_settings['color_profile_management'] and transform_settings['target_color_profile'] == "RGB":
|
||||||
if len(processed_image_data.shape) == 3 and processed_image_data.shape[2] == 3:
|
if len(base_pot_image_data.shape) == 3 and base_pot_image_data.shape[2] == 3: # BGR to RGB
|
||||||
logger.info(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Converting BGR to RGB.")
|
logger.info(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Converting BGR to RGB for base POT image.")
|
||||||
processed_image_data = ipu.convert_bgr_to_rgb(processed_image_data)
|
base_pot_image_data = ipu.convert_bgr_to_rgb(base_pot_image_data)
|
||||||
elif len(processed_image_data.shape) == 3 and processed_image_data.shape[2] == 4:
|
elif len(base_pot_image_data.shape) == 3 and base_pot_image_data.shape[2] == 4: # BGRA to RGBA
|
||||||
logger.info(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Converting BGRA to RGBA.")
|
logger.info(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Converting BGRA to RGBA for base POT image.")
|
||||||
processed_image_data = ipu.convert_bgra_to_rgba(processed_image_data)
|
base_pot_image_data = ipu.convert_bgra_to_rgba(base_pot_image_data)
|
||||||
|
|
||||||
|
# Ensure engine_temp_dir exists before saving base POT
|
||||||
if not context.engine_temp_dir.exists():
|
if not context.engine_temp_dir.exists():
|
||||||
try:
|
try:
|
||||||
context.engine_temp_dir.mkdir(parents=True, exist_ok=True)
|
context.engine_temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
logger.info(f"Asset '{asset_name_for_log}': Created engine_temp_dir at '{context.engine_temp_dir}'")
|
logger.info(f"Asset '{asset_name_for_log}': Created engine_temp_dir at '{context.engine_temp_dir}'")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.error(f"Asset '{asset_name_for_log}': Failed to create engine_temp_dir '{context.engine_temp_dir}': {e}")
|
logger.error(f"Asset '{asset_name_for_log}': Failed to create engine_temp_dir '{context.engine_temp_dir}': {e}")
|
||||||
self._update_file_rule_status(context, current_map_id_hex, 'Failed', map_type=current_map_type, source_file=str(source_file_path), details="Failed to create temp directory")
|
self._update_file_rule_status(context, current_map_id_hex, 'Failed', map_type=filename_friendly_map_type, source_file=str(source_file_path), details="Failed to create temp directory for base POT")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
temp_filename_suffix = Path(source_file_path).suffix
|
temp_filename_suffix = Path(source_file_path).suffix
|
||||||
safe_map_type_filename = sanitize_filename(current_map_type)
|
base_pot_temp_filename = f"{current_map_id_hex}_basePOT{temp_filename_suffix}"
|
||||||
temp_output_filename = f"processed_{safe_map_type_filename}_{current_map_id_hex}{temp_filename_suffix}"
|
base_pot_temp_path = context.engine_temp_dir / base_pot_temp_filename
|
||||||
temp_output_path = context.engine_temp_dir / temp_output_filename
|
|
||||||
|
|
||||||
save_params = []
|
# Determine save parameters for base POT image (can be different from variants if needed)
|
||||||
if transform_settings['output_format_settings']:
|
base_save_params = []
|
||||||
if temp_filename_suffix.lower() in ['.jpg', '.jpeg']:
|
base_output_ext = temp_filename_suffix.lstrip('.') # Default to original, can be overridden by format rules
|
||||||
quality = transform_settings['output_format_settings'].get('quality', 95)
|
# TODO: Add logic here to determine base_output_ext and base_save_params based on bit depth and config, similar to variants.
|
||||||
save_params = [cv2.IMWRITE_JPEG_QUALITY, quality]
|
# For now, using simple save.
|
||||||
elif temp_filename_suffix.lower() == '.png':
|
|
||||||
compression = transform_settings['output_format_settings'].get('compression_level', 3)
|
|
||||||
save_params = [cv2.IMWRITE_PNG_COMPRESSION, compression]
|
|
||||||
|
|
||||||
save_success = ipu.save_image(str(temp_output_path), processed_image_data, params=save_params)
|
if not ipu.save_image(str(base_pot_temp_path), base_pot_image_data, params=base_save_params):
|
||||||
|
logger.error(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Failed to save base POT image to '{base_pot_temp_path}'.")
|
||||||
if not save_success:
|
self._update_file_rule_status(context, current_map_id_hex, 'Failed', map_type=filename_friendly_map_type, source_file=str(source_file_path), original_dimensions=(original_width, original_height), base_pot_dimensions=(base_pot_width, base_pot_height), details="Base POT image save failed")
|
||||||
logger.error(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Failed to save temporary image to '{temp_output_path}'.")
|
|
||||||
self._update_file_rule_status(context, current_map_id_hex, 'Failed', map_type=current_map_type, source_file=str(source_file_path), original_dimensions=(original_width, original_height), processed_dimensions=(processed_image_data.shape[1], processed_image_data.shape[0]) if processed_image_data is not None else None, details="Temporary image save failed")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"Asset '{asset_name_for_log}', FileRule path '{file_rule.file_path}' (ID: {current_map_id_hex}): Successfully processed and saved temporary map to '{temp_output_path}'.")
|
logger.info(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Successfully saved base POT image to '{base_pot_temp_path}' with dims ({base_pot_width}x{base_pot_height}).")
|
||||||
|
|
||||||
self._update_file_rule_status(context, current_map_id_hex, 'Processed', map_type=current_map_type, source_file=str(source_file_path), temp_processed_file=str(temp_output_path), original_dimensions=(original_width, original_height), processed_dimensions=(processed_image_data.shape[1], processed_image_data.shape[0]), details="Successfully processed")
|
# Initialize/update the status for this map in processed_maps_details
|
||||||
|
self._update_file_rule_status(
|
||||||
|
context,
|
||||||
|
current_map_id_hex,
|
||||||
|
'BasePOTSaved', # Intermediate status, will be updated after variant check
|
||||||
|
map_type=filename_friendly_map_type,
|
||||||
|
source_file=str(source_file_path),
|
||||||
|
original_dimensions=(original_width, original_height),
|
||||||
|
base_pot_dimensions=(base_pot_width, base_pot_height),
|
||||||
|
temp_processed_file=str(base_pot_temp_path) # Store path to the saved base POT
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Multiple Resolution Output (Variants)
|
||||||
|
processed_at_least_one_resolution_variant = False
|
||||||
|
# Resolution variants are attempted for all map types individually processed.
|
||||||
|
# The filter at the beginning of the loop (around line 72) ensures only relevant maps reach this stage.
|
||||||
|
generate_variants_for_this_map_type = True
|
||||||
|
|
||||||
|
if generate_variants_for_this_map_type: # This will now always be true if code execution reaches here
|
||||||
|
logger.info(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Map type '{current_map_type}' is eligible for individual processing. Attempting to generate resolution variants.")
|
||||||
|
# Sort resolutions from largest to smallest
|
||||||
|
sorted_resolutions = sorted(image_resolutions.items(), key=lambda item: item[1], reverse=True)
|
||||||
|
logger.debug(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Sorted resolutions for variant processing: {sorted_resolutions}")
|
||||||
|
|
||||||
|
for res_key, res_max_dim in sorted_resolutions:
|
||||||
|
current_w, current_h = base_pot_image_data.shape[1], base_pot_image_data.shape[0]
|
||||||
|
|
||||||
|
if current_w <= 0 or current_h <=0:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}, Res {res_key}: Base POT image has zero dimension ({current_w}x{current_h}). Skipping this resolution variant.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if max(current_w, current_h) >= res_max_dim:
|
||||||
|
target_w_res, target_h_res = current_w, current_h
|
||||||
|
if max(current_w, current_h) > res_max_dim:
|
||||||
|
if current_w >= current_h:
|
||||||
|
target_w_res = res_max_dim
|
||||||
|
target_h_res = max(1, round(target_w_res / (current_w / current_h)))
|
||||||
|
else:
|
||||||
|
target_h_res = res_max_dim
|
||||||
|
target_w_res = max(1, round(target_h_res * (current_w / current_h)))
|
||||||
|
else:
|
||||||
|
logger.debug(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}, Res {res_key}: Base POT image ({current_w}x{current_h}) is smaller than target max dim {res_max_dim}. Skipping this resolution variant.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
target_w_res = min(target_w_res, current_w)
|
||||||
|
target_h_res = min(target_h_res, current_h)
|
||||||
|
|
||||||
|
if target_w_res <=0 or target_h_res <=0:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}, Res {res_key}: Calculated target variant dims are zero or negative ({target_w_res}x{target_h_res}). Skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}, Res {res_key}: Processing variant for {res_max_dim}. Base POT Dims: ({current_w}x{current_h}), Target Dims for {res_key}: ({target_w_res}x{target_h_res}).")
|
||||||
|
|
||||||
|
output_image_data_for_res = base_pot_image_data
|
||||||
|
if (target_w_res, target_h_res) != (current_w, current_h):
|
||||||
|
interpolation_res = cv2.INTER_AREA
|
||||||
|
output_image_data_for_res = ipu.resize_image(base_pot_image_data, target_w_res, target_h_res, interpolation=interpolation_res)
|
||||||
|
if output_image_data_for_res is None:
|
||||||
|
logger.error(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}, Res {res_key}: Failed to resize image for resolution variant {res_key}.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
assetname_placeholder = context.asset_rule.asset_name if context.asset_rule else "UnknownAsset"
|
||||||
|
resolution_placeholder = res_key
|
||||||
|
|
||||||
|
# TODO: Implement proper output format/extension determination for variants
|
||||||
|
output_ext_variant = temp_filename_suffix.lstrip('.')
|
||||||
|
|
||||||
|
temp_output_filename_variant = output_filename_pattern.replace("[assetname]", sanitize_filename(assetname_placeholder)) \
|
||||||
|
.replace("[maptype]", sanitize_filename(filename_friendly_map_type)) \
|
||||||
|
.replace("[resolution]", sanitize_filename(resolution_placeholder)) \
|
||||||
|
.replace("[ext]", output_ext_variant)
|
||||||
|
temp_output_filename_variant = f"{current_map_id_hex}_variant_{temp_output_filename_variant}" # Distinguish variant temp files
|
||||||
|
temp_output_path_variant = context.engine_temp_dir / temp_output_filename_variant
|
||||||
|
|
||||||
|
save_params_variant = []
|
||||||
|
if transform_settings.get('output_format_settings'):
|
||||||
|
if output_ext_variant.lower() in ['jpg', 'jpeg']:
|
||||||
|
quality = transform_settings['output_format_settings'].get('quality', context.config_obj.get("JPG_QUALITY", 95))
|
||||||
|
save_params_variant = [cv2.IMWRITE_JPEG_QUALITY, quality]
|
||||||
|
elif output_ext_variant.lower() == 'png':
|
||||||
|
compression = transform_settings['output_format_settings'].get('compression_level', context.config_obj.get("PNG_COMPRESSION_LEVEL", 6))
|
||||||
|
save_params_variant = [cv2.IMWRITE_PNG_COMPRESSION, compression]
|
||||||
|
|
||||||
|
save_success_variant = ipu.save_image(str(temp_output_path_variant), output_image_data_for_res, params=save_params_variant)
|
||||||
|
|
||||||
|
if not save_success_variant:
|
||||||
|
logger.error(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}, Res {res_key}: Failed to save temporary variant image to '{temp_output_path_variant}'.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}, Res {res_key}: Successfully saved temporary variant map to '{temp_output_path_variant}' with dims ({target_w_res}x{target_h_res}).")
|
||||||
|
processed_at_least_one_resolution_variant = True
|
||||||
|
|
||||||
|
if 'variants' not in context.processed_maps_details[current_map_id_hex]:
|
||||||
|
context.processed_maps_details[current_map_id_hex]['variants'] = []
|
||||||
|
|
||||||
|
context.processed_maps_details[current_map_id_hex]['variants'].append({
|
||||||
|
'resolution_key': res_key,
|
||||||
|
'temp_path': str(temp_output_path_variant), # Changed 'path' to 'temp_path'
|
||||||
|
'dimensions': (target_w_res, target_h_res),
|
||||||
|
'resolution_name': f"{target_w_res}x{target_h_res}" # Retain for potential use
|
||||||
|
})
|
||||||
|
|
||||||
if 'processed_files' not in context.asset_metadata:
|
if 'processed_files' not in context.asset_metadata:
|
||||||
context.asset_metadata['processed_files'] = []
|
context.asset_metadata['processed_files'] = []
|
||||||
context.asset_metadata['processed_files'].append({
|
context.asset_metadata['processed_files'].append({
|
||||||
'processed_map_key': current_map_id_hex, # Changed from file_rule_id
|
'processed_map_key': current_map_id_hex,
|
||||||
'path': str(temp_output_path),
|
'resolution_key': res_key,
|
||||||
'type': 'temporary_map',
|
'path': str(temp_output_path_variant),
|
||||||
'map_type': current_map_type
|
'type': 'temporary_map_variant',
|
||||||
|
'map_type': current_map_type,
|
||||||
|
'dimensions_w': target_w_res,
|
||||||
|
'dimensions_h': target_h_res
|
||||||
})
|
})
|
||||||
|
# Calculate and store image statistics for the lowest resolution output
|
||||||
|
lowest_res_image_data_for_stats = None
|
||||||
|
image_to_stat_path_for_log = "N/A"
|
||||||
|
source_of_stats_image = "unknown"
|
||||||
|
|
||||||
|
if processed_at_least_one_resolution_variant and \
|
||||||
|
current_map_id_hex in context.processed_maps_details and \
|
||||||
|
'variants' in context.processed_maps_details[current_map_id_hex] and \
|
||||||
|
context.processed_maps_details[current_map_id_hex]['variants']:
|
||||||
|
|
||||||
|
variants_list = context.processed_maps_details[current_map_id_hex]['variants']
|
||||||
|
valid_variants_for_stats = [
|
||||||
|
v for v in variants_list
|
||||||
|
if isinstance(v.get('dimensions'), tuple) and len(v['dimensions']) == 2 and v['dimensions'][0] > 0 and v['dimensions'][1] > 0
|
||||||
|
]
|
||||||
|
|
||||||
|
if valid_variants_for_stats:
|
||||||
|
smallest_variant = min(valid_variants_for_stats, key=lambda v: v['dimensions'][0] * v['dimensions'][1])
|
||||||
|
|
||||||
|
if smallest_variant and 'temp_path' in smallest_variant and smallest_variant.get('dimensions'):
|
||||||
|
smallest_res_w, smallest_res_h = smallest_variant['dimensions']
|
||||||
|
logger.info(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Identified smallest variant for stats: {smallest_variant.get('resolution_key', 'N/A')} ({smallest_res_w}x{smallest_res_h}) at {smallest_variant['temp_path']}")
|
||||||
|
lowest_res_image_data_for_stats = ipu.load_image(smallest_variant['temp_path'])
|
||||||
|
image_to_stat_path_for_log = smallest_variant['temp_path']
|
||||||
|
source_of_stats_image = f"variant {smallest_variant.get('resolution_key', 'N/A')}"
|
||||||
|
if lowest_res_image_data_for_stats is None:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Failed to load smallest variant image '{smallest_variant['temp_path']}' for stats.")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Could not determine smallest variant for stats from valid variants list (details missing).")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: No valid variants found to determine the smallest one for stats.")
|
||||||
|
|
||||||
|
if lowest_res_image_data_for_stats is None:
|
||||||
|
if base_pot_image_data is not None:
|
||||||
|
logger.info(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Using base POT image for stats (dimensions: {base_pot_width}x{base_pot_height}). Smallest variant not available/loaded or no variants generated.")
|
||||||
|
lowest_res_image_data_for_stats = base_pot_image_data
|
||||||
|
image_to_stat_path_for_log = f"In-memory base POT image (dims: {base_pot_width}x{base_pot_height})"
|
||||||
|
source_of_stats_image = "base POT"
|
||||||
|
else:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Base POT image data is also None. Cannot calculate stats.")
|
||||||
|
|
||||||
|
if lowest_res_image_data_for_stats is not None:
|
||||||
|
stats_dict = ipu.calculate_image_stats(lowest_res_image_data_for_stats)
|
||||||
|
if stats_dict and "error" not in stats_dict:
|
||||||
|
if 'image_stats_lowest_res' not in context.asset_metadata:
|
||||||
|
context.asset_metadata['image_stats_lowest_res'] = {}
|
||||||
|
|
||||||
|
context.asset_metadata['image_stats_lowest_res'][current_map_type] = stats_dict
|
||||||
|
logger.info(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}, Map Type '{current_map_type}': Calculated and stored image stats from '{source_of_stats_image}' (source ref: '{image_to_stat_path_for_log}').")
|
||||||
|
elif stats_dict and "error" in stats_dict:
|
||||||
|
logger.error(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}, Map Type '{current_map_type}': Error calculating image stats from '{source_of_stats_image}': {stats_dict['error']}.")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}, Map Type '{current_map_type}': Failed to calculate image stats from '{source_of_stats_image}' (result was None or empty).")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}, Map Type '{current_map_type}': No image data available (from variant or base POT) to calculate stats.")
|
||||||
|
|
||||||
|
# Final status update based on whether variants were generated (and expected)
|
||||||
|
if generate_variants_for_this_map_type:
|
||||||
|
if processed_at_least_one_resolution_variant:
|
||||||
|
self._update_file_rule_status(context, current_map_id_hex, 'Processed_With_Variants', map_type=filename_friendly_map_type, details="Successfully processed with multiple resolution variants.")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {current_map_id_hex}: Variants were expected for map type '{current_map_type}', but none were generated (e.g., base POT too small for any variant tier).")
|
||||||
|
self._update_file_rule_status(context, current_map_id_hex, 'Processed_No_Variants', map_type=filename_friendly_map_type, details="Variants expected but none generated (e.g., base POT too small).")
|
||||||
|
else: # No variants were expected for this map type
|
||||||
|
self._update_file_rule_status(context, current_map_id_hex, 'Processed_No_Variants', map_type=filename_friendly_map_type, details="Processed to base POT; variants not applicable for this map type.")
|
||||||
|
|
||||||
logger.info(f"Asset '{asset_name_for_log}': Finished individual map processing stage.")
|
logger.info(f"Asset '{asset_name_for_log}': Finished individual map processing stage.")
|
||||||
return context
|
return context
|
||||||
@ -260,13 +606,34 @@ class IndividualMapProcessingStage(ProcessingStage):
|
|||||||
orig_w, orig_h = kwargs['original_dimensions']
|
orig_w, orig_h = kwargs['original_dimensions']
|
||||||
context.processed_maps_details[map_id_hex]['original_resolution_name'] = f"{orig_w}x{orig_h}"
|
context.processed_maps_details[map_id_hex]['original_resolution_name'] = f"{orig_w}x{orig_h}"
|
||||||
|
|
||||||
if status == 'Processed' and 'processed_dimensions' in kwargs and isinstance(kwargs['processed_dimensions'], tuple) and len(kwargs['processed_dimensions']) == 2:
|
# Determine the correct dimensions to use for 'processed_resolution_name'
|
||||||
proc_w, proc_h = kwargs['processed_dimensions']
|
# This name refers to the base POT scaled image dimensions before variant generation.
|
||||||
context.processed_maps_details[map_id_hex]['processed_resolution_name'] = f"{proc_w}x{proc_h}"
|
dims_to_log_as_base_processed = None
|
||||||
elif 'processed_dimensions' in kwargs: # If present but not as expected, log or handle
|
if 'base_pot_dimensions' in kwargs and isinstance(kwargs['base_pot_dimensions'], tuple) and len(kwargs['base_pot_dimensions']) == 2:
|
||||||
logger.warning(f"Asset '{asset_name_for_log}', Map ID {map_id_hex}: 'processed_dimensions' present but not a valid tuple: {kwargs['processed_dimensions']}")
|
# This key is used when status is 'Processed_With_Variants'
|
||||||
|
dims_to_log_as_base_processed = kwargs['base_pot_dimensions']
|
||||||
|
elif 'processed_dimensions' in kwargs and isinstance(kwargs['processed_dimensions'], tuple) and len(kwargs['processed_dimensions']) == 2:
|
||||||
|
# This key is used when status is 'Processed_No_Variants' (and potentially others)
|
||||||
|
dims_to_log_as_base_processed = kwargs['processed_dimensions']
|
||||||
|
|
||||||
|
if dims_to_log_as_base_processed:
|
||||||
|
proc_w, proc_h = dims_to_log_as_base_processed
|
||||||
|
resolution_name_str = f"{proc_w}x{proc_h}"
|
||||||
|
context.processed_maps_details[map_id_hex]['base_pot_resolution_name'] = resolution_name_str
|
||||||
|
# Ensure 'processed_resolution_name' is also set for OutputOrganizationStage compatibility
|
||||||
|
context.processed_maps_details[map_id_hex]['processed_resolution_name'] = resolution_name_str
|
||||||
|
elif 'processed_dimensions' in kwargs or 'base_pot_dimensions' in kwargs:
|
||||||
|
details_for_warning = kwargs.get('processed_dimensions', kwargs.get('base_pot_dimensions'))
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}', Map ID {map_id_hex}: 'processed_dimensions' or 'base_pot_dimensions' key present but its value is not a valid 2-element tuple: {details_for_warning}")
|
||||||
|
|
||||||
|
# If temp_processed_file was passed, ensure it's in the details
|
||||||
|
if 'temp_processed_file' in kwargs:
|
||||||
|
context.processed_maps_details[map_id_hex]['temp_processed_file'] = kwargs['temp_processed_file']
|
||||||
|
|
||||||
|
|
||||||
# Log all details being stored for clarity, including the newly added resolution names
|
# Log all details being stored for clarity, including the newly added resolution names
|
||||||
log_details = context.processed_maps_details[map_id_hex].copy()
|
log_details = context.processed_maps_details[map_id_hex].copy()
|
||||||
|
# Avoid logging full image data if it accidentally gets into kwargs
|
||||||
|
if 'image_data' in log_details: del log_details['image_data']
|
||||||
|
if 'base_pot_image_data' in log_details: del log_details['base_pot_image_data']
|
||||||
logger.debug(f"Asset '{asset_name_for_log}', Map ID {map_id_hex}: Status updated to '{status}'. Details: {log_details}")
|
logger.debug(f"Asset '{asset_name_for_log}', Map ID {map_id_hex}: Status updated to '{status}'. Details: {log_details}")
|
||||||
@ -217,9 +217,28 @@ class MapMergingStage(ProcessingStage):
|
|||||||
if source_image is not None:
|
if source_image is not None:
|
||||||
if source_image.ndim == 2: # Grayscale source
|
if source_image.ndim == 2: # Grayscale source
|
||||||
source_data_this_channel = source_image
|
source_data_this_channel = source_image
|
||||||
elif source_image.ndim == 3: # Color source, take the first channel (assuming it's grayscale or R of RGB)
|
elif source_image.ndim == 3 or source_image.ndim == 4: # Color source (3-channel BGR or 4-channel BGRA), assumed loaded by ipu.load_image
|
||||||
|
# Standard BGR(A) channel indexing: B=0, G=1, R=2, A=3 (if present)
|
||||||
|
# This map helps get NRM's Red data for 'R' output, NRM's Green for 'G' output etc.
|
||||||
|
# based on the semantic meaning of out_channel_char.
|
||||||
|
semantic_to_bgr_idx = {'R': 2, 'G': 1, 'B': 0, 'A': 3}
|
||||||
|
|
||||||
|
if input_map_type_for_this_channel == "NRM":
|
||||||
|
idx_to_extract = semantic_to_bgr_idx.get(out_channel_char)
|
||||||
|
|
||||||
|
if idx_to_extract is not None and idx_to_extract < source_image.shape[2]:
|
||||||
|
source_data_this_channel = source_image[:, :, idx_to_extract]
|
||||||
|
logger.debug(f"Asset {asset_name_for_log}, Merge Op ID {merge_op_id}: For output '{out_channel_char}', using NRM's semantic '{out_channel_char}' channel (BGR(A) index {idx_to_extract}).")
|
||||||
|
else:
|
||||||
|
# Fallback if out_channel_char isn't R,G,B,A or NRM doesn't have the channel (e.g. 3-channel NRM and 'A' requested)
|
||||||
|
logger.warning(f"Asset {asset_name_for_log}, Merge Op ID {merge_op_id}: Could not map output '{out_channel_char}' to a specific BGR(A) channel of NRM (shape {source_image.shape}). Defaulting to NRM's channel 0 (Blue).")
|
||||||
source_data_this_channel = source_image[:, :, 0]
|
source_data_this_channel = source_image[:, :, 0]
|
||||||
logger.debug(f"Asset {asset_name_for_log}, Merge Op ID {merge_op_id}: Taking channel 0 from {input_map_type_for_this_channel} for output {out_channel_char}.")
|
else:
|
||||||
|
# For other multi-channel sources (e.g., ROUGH as RGB, or other color maps not "NRM")
|
||||||
|
# Default to taking the first channel (Blue in BGR).
|
||||||
|
# This covers "Roughness map's greyscale data" if ROUGH is RGB (by taking one of its channels as a proxy).
|
||||||
|
source_data_this_channel = source_image[:, :, 0]
|
||||||
|
logger.debug(f"Asset {asset_name_for_log}, Merge Op ID {merge_op_id}: For output '{out_channel_char}', source {input_map_type_for_this_channel} (shape {source_image.shape}) is multi-channel but not NRM. Using its channel 0 (Blue).")
|
||||||
else: # Source map was not found, use default
|
else: # Source map was not found, use default
|
||||||
default_val_for_channel = default_values.get(out_channel_char)
|
default_val_for_channel = default_values.get(out_channel_char)
|
||||||
if default_val_for_channel is not None:
|
if default_val_for_channel is not None:
|
||||||
|
|||||||
@ -125,6 +125,26 @@ class MetadataFinalizationAndSaveStage(ProcessingStage):
|
|||||||
|
|
||||||
def make_serializable(data: Any) -> Any:
|
def make_serializable(data: Any) -> Any:
|
||||||
if isinstance(data, Path):
|
if isinstance(data, Path):
|
||||||
|
# metadata_save_path is available from the outer scope
|
||||||
|
metadata_dir = metadata_save_path.parent
|
||||||
|
try:
|
||||||
|
# Attempt to make the path relative if it's absolute and under the same root
|
||||||
|
if data.is_absolute():
|
||||||
|
# Check if the path can be made relative (e.g., same drive on Windows)
|
||||||
|
# This check might need to be more robust depending on os.path.relpath behavior
|
||||||
|
# For pathlib, relative_to will raise ValueError if not possible.
|
||||||
|
return str(data.relative_to(metadata_dir))
|
||||||
|
else:
|
||||||
|
# If it's already relative, assume it's correct or handle as needed
|
||||||
|
return str(data)
|
||||||
|
except ValueError:
|
||||||
|
# If paths are on different drives or cannot be made relative,
|
||||||
|
# log a warning and return the absolute path as a string.
|
||||||
|
# This can happen if an output path was explicitly set to an unrelated directory.
|
||||||
|
logger.warning(
|
||||||
|
f"Asset '{asset_name_for_log}': Could not make path {data} "
|
||||||
|
f"relative to {metadata_dir}. Storing as absolute."
|
||||||
|
)
|
||||||
return str(data)
|
return str(data)
|
||||||
if isinstance(data, datetime.datetime): # Ensure datetime is serializable
|
if isinstance(data, datetime.datetime): # Ensure datetime is serializable
|
||||||
return data.isoformat()
|
return data.isoformat()
|
||||||
|
|||||||
@ -50,34 +50,33 @@ class OutputOrganizationStage(ProcessingStage):
|
|||||||
|
|
||||||
# A. Organize Processed Individual Maps
|
# A. Organize Processed Individual Maps
|
||||||
if context.processed_maps_details:
|
if context.processed_maps_details:
|
||||||
logger.debug(f"Asset '{asset_name_for_log}': Organizing {len(context.processed_maps_details)} processed individual map(s).")
|
logger.debug(f"Asset '{asset_name_for_log}': Organizing {len(context.processed_maps_details)} processed individual map entries.")
|
||||||
for processed_map_key, details in context.processed_maps_details.items(): # Use processed_map_key
|
for processed_map_key, details in context.processed_maps_details.items():
|
||||||
if details.get('status') != 'Processed' or not details.get('temp_processed_file'):
|
map_status = details.get('status')
|
||||||
logger.debug(f"Asset '{asset_name_for_log}': Skipping processed map key '{processed_map_key}' due to status '{details.get('status')}' or missing temp file.")
|
base_map_type = details.get('map_type', 'unknown_map_type') # Original map type
|
||||||
|
|
||||||
|
if map_status in ['Processed', 'Processed_No_Variants']:
|
||||||
|
if not details.get('temp_processed_file'):
|
||||||
|
logger.debug(f"Asset '{asset_name_for_log}': Skipping map key '{processed_map_key}' (status '{map_status}') due to missing 'temp_processed_file'.")
|
||||||
|
details['status'] = 'Organization Skipped (Missing Temp File)'
|
||||||
continue
|
continue
|
||||||
|
|
||||||
temp_file_path = Path(details['temp_processed_file'])
|
temp_file_path = Path(details['temp_processed_file'])
|
||||||
map_type = details.get('map_type', 'unknown_map_type')
|
|
||||||
resolution_str = details.get('processed_resolution_name', details.get('original_resolution_name', 'resX'))
|
resolution_str = details.get('processed_resolution_name', details.get('original_resolution_name', 'resX'))
|
||||||
|
|
||||||
|
|
||||||
# Construct token_data for path generation
|
|
||||||
token_data = {
|
token_data = {
|
||||||
"assetname": asset_name_for_log,
|
"assetname": asset_name_for_log,
|
||||||
"supplier": context.effective_supplier or "DefaultSupplier",
|
"supplier": context.effective_supplier or "DefaultSupplier",
|
||||||
"maptype": map_type,
|
"maptype": base_map_type,
|
||||||
"resolution": resolution_str,
|
"resolution": resolution_str,
|
||||||
"ext": temp_file_path.suffix.lstrip('.'), # Get extension without dot
|
"ext": temp_file_path.suffix.lstrip('.'),
|
||||||
"incrementingvalue": getattr(context, 'incrementing_value', None),
|
"incrementingvalue": getattr(context, 'incrementing_value', None),
|
||||||
"sha5": getattr(context, 'sha5_value', None)
|
"sha5": getattr(context, 'sha5_value', None)
|
||||||
}
|
}
|
||||||
token_data_cleaned = {k: v for k, v in token_data.items() if v is not None}
|
token_data_cleaned = {k: v for k, v in token_data.items() if v is not None}
|
||||||
|
|
||||||
# Generate filename first using its pattern
|
|
||||||
# output_filename = f"{asset_name_for_log}_{sanitize_filename(map_type)}{temp_file_path.suffix}" # Old way
|
|
||||||
output_filename = generate_path_from_pattern(output_filename_pattern_config, token_data_cleaned)
|
output_filename = generate_path_from_pattern(output_filename_pattern_config, token_data_cleaned)
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
relative_dir_path_str = generate_path_from_pattern(
|
relative_dir_path_str = generate_path_from_pattern(
|
||||||
pattern_string=output_dir_pattern,
|
pattern_string=output_dir_pattern,
|
||||||
@ -87,20 +86,166 @@ class OutputOrganizationStage(ProcessingStage):
|
|||||||
final_path.parent.mkdir(parents=True, exist_ok=True)
|
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
if final_path.exists() and not overwrite_existing:
|
if final_path.exists() and not overwrite_existing:
|
||||||
logger.info(f"Asset '{asset_name_for_log}': Output file {final_path} exists and overwrite is disabled. Skipping copy.")
|
logger.info(f"Asset '{asset_name_for_log}': Output file {final_path} for map '{processed_map_key}' exists and overwrite is disabled. Skipping copy.")
|
||||||
else:
|
else:
|
||||||
shutil.copy2(temp_file_path, final_path)
|
shutil.copy2(temp_file_path, final_path)
|
||||||
logger.info(f"Asset '{asset_name_for_log}': Copied {temp_file_path} to {final_path}")
|
logger.info(f"Asset '{asset_name_for_log}': Copied {temp_file_path} to {final_path} for map '{processed_map_key}'.")
|
||||||
final_output_files.append(str(final_path))
|
final_output_files.append(str(final_path))
|
||||||
|
|
||||||
context.processed_maps_details[processed_map_key]['final_output_path'] = str(final_path)
|
details['final_output_path'] = str(final_path)
|
||||||
context.processed_maps_details[processed_map_key]['status'] = 'Organized'
|
details['status'] = 'Organized'
|
||||||
|
|
||||||
|
# Update asset_metadata for metadata.json
|
||||||
|
map_metadata_entry = context.asset_metadata.setdefault('maps', {}).setdefault(processed_map_key, {})
|
||||||
|
map_metadata_entry['map_type'] = base_map_type
|
||||||
|
map_metadata_entry['path'] = str(Path(relative_dir_path_str) / Path(output_filename)) # Store relative path
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Asset '{asset_name_for_log}': Failed to copy {temp_file_path} to destination for processed map key '{processed_map_key}'. Error: {e}", exc_info=True)
|
logger.error(f"Asset '{asset_name_for_log}': Failed to copy {temp_file_path} for map key '{processed_map_key}'. Error: {e}", exc_info=True)
|
||||||
context.status_flags['output_organization_error'] = True
|
context.status_flags['output_organization_error'] = True
|
||||||
context.asset_metadata['status'] = "Failed (Output Organization Error)"
|
context.asset_metadata['status'] = "Failed (Output Organization Error)"
|
||||||
context.processed_maps_details[processed_map_key]['status'] = 'Organization Failed'
|
details['status'] = 'Organization Failed'
|
||||||
|
|
||||||
|
elif map_status == 'Processed_With_Variants':
|
||||||
|
variants = details.get('variants')
|
||||||
|
if not variants: # No variants list, or it's empty
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}': Map key '{processed_map_key}' (status '{map_status}') has no 'variants' list or it is empty. Attempting fallback to base file.")
|
||||||
|
if not details.get('temp_processed_file'):
|
||||||
|
logger.error(f"Asset '{asset_name_for_log}': Skipping map key '{processed_map_key}' (fallback) as 'temp_processed_file' is also missing.")
|
||||||
|
details['status'] = 'Organization Failed (No Variants, No Temp File)'
|
||||||
|
continue # Skip to next map key
|
||||||
|
|
||||||
|
# Fallback: Process the base temp_processed_file
|
||||||
|
temp_file_path = Path(details['temp_processed_file'])
|
||||||
|
resolution_str = details.get('processed_resolution_name', details.get('original_resolution_name', 'baseRes'))
|
||||||
|
|
||||||
|
token_data = {
|
||||||
|
"assetname": asset_name_for_log,
|
||||||
|
"supplier": context.effective_supplier or "DefaultSupplier",
|
||||||
|
"maptype": base_map_type,
|
||||||
|
"resolution": resolution_str,
|
||||||
|
"ext": temp_file_path.suffix.lstrip('.'),
|
||||||
|
"incrementingvalue": getattr(context, 'incrementing_value', None),
|
||||||
|
"sha5": getattr(context, 'sha5_value', None)
|
||||||
|
}
|
||||||
|
token_data_cleaned = {k: v for k, v in token_data.items() if v is not None}
|
||||||
|
output_filename = generate_path_from_pattern(output_filename_pattern_config, token_data_cleaned)
|
||||||
|
|
||||||
|
try:
|
||||||
|
relative_dir_path_str = generate_path_from_pattern(
|
||||||
|
pattern_string=output_dir_pattern,
|
||||||
|
token_data=token_data_cleaned
|
||||||
|
)
|
||||||
|
final_path = Path(context.output_base_path) / Path(relative_dir_path_str) / Path(output_filename)
|
||||||
|
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if final_path.exists() and not overwrite_existing:
|
||||||
|
logger.info(f"Asset '{asset_name_for_log}': Output file {final_path} for map '{processed_map_key}' (fallback) exists and overwrite is disabled. Skipping copy.")
|
||||||
|
else:
|
||||||
|
shutil.copy2(temp_file_path, final_path)
|
||||||
|
logger.info(f"Asset '{asset_name_for_log}': Copied {temp_file_path} to {final_path} for map '{processed_map_key}' (fallback).")
|
||||||
|
final_output_files.append(str(final_path))
|
||||||
|
|
||||||
|
details['final_output_path'] = str(final_path)
|
||||||
|
details['status'] = 'Organized (Base File Fallback)'
|
||||||
|
|
||||||
|
map_metadata_entry = context.asset_metadata.setdefault('maps', {}).setdefault(processed_map_key, {})
|
||||||
|
map_metadata_entry['map_type'] = base_map_type
|
||||||
|
map_metadata_entry['path'] = str(Path(relative_dir_path_str) / Path(output_filename))
|
||||||
|
if 'variant_paths' in map_metadata_entry: # Clean up if it was somehow set
|
||||||
|
del map_metadata_entry['variant_paths']
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Asset '{asset_name_for_log}': Failed to copy {temp_file_path} (fallback) for map key '{processed_map_key}'. Error: {e}", exc_info=True)
|
||||||
|
context.status_flags['output_organization_error'] = True
|
||||||
|
context.asset_metadata['status'] = "Failed (Output Organization Error - Fallback)"
|
||||||
|
details['status'] = 'Organization Failed (Fallback)'
|
||||||
|
continue # Finished with this map key due to fallback
|
||||||
|
|
||||||
|
# If we are here, 'variants' list exists and is not empty. Proceed with variant processing.
|
||||||
|
logger.debug(f"Asset '{asset_name_for_log}': Organizing {len(variants)} variants for map key '{processed_map_key}' (map type: {base_map_type}).")
|
||||||
|
|
||||||
|
map_metadata_entry = context.asset_metadata.setdefault('maps', {}).setdefault(processed_map_key, {})
|
||||||
|
map_metadata_entry['map_type'] = base_map_type
|
||||||
|
map_metadata_entry.setdefault('variant_paths', {}) # Initialize if not present
|
||||||
|
|
||||||
|
processed_any_variant_successfully = False
|
||||||
|
failed_any_variant = False
|
||||||
|
|
||||||
|
for variant_index, variant_detail in enumerate(variants):
|
||||||
|
temp_variant_path_str = variant_detail.get('temp_path')
|
||||||
|
if not temp_variant_path_str:
|
||||||
|
logger.warning(f"Asset '{asset_name_for_log}': Variant {variant_index} for map '{processed_map_key}' is missing 'temp_path'. Skipping.")
|
||||||
|
variant_detail['status'] = 'Organization Skipped (Missing Temp Path)'
|
||||||
|
continue
|
||||||
|
|
||||||
|
temp_variant_path = Path(temp_variant_path_str)
|
||||||
|
variant_resolution_key = variant_detail.get('resolution_key', f"varRes{variant_index}")
|
||||||
|
variant_ext = temp_variant_path.suffix.lstrip('.')
|
||||||
|
|
||||||
|
token_data_variant = {
|
||||||
|
"assetname": asset_name_for_log,
|
||||||
|
"supplier": context.effective_supplier or "DefaultSupplier",
|
||||||
|
"maptype": base_map_type,
|
||||||
|
"resolution": variant_resolution_key,
|
||||||
|
"ext": variant_ext,
|
||||||
|
"incrementingvalue": getattr(context, 'incrementing_value', None),
|
||||||
|
"sha5": getattr(context, 'sha5_value', None)
|
||||||
|
}
|
||||||
|
token_data_variant_cleaned = {k: v for k, v in token_data_variant.items() if v is not None}
|
||||||
|
output_filename_variant = generate_path_from_pattern(output_filename_pattern_config, token_data_variant_cleaned)
|
||||||
|
|
||||||
|
try:
|
||||||
|
relative_dir_path_str_variant = generate_path_from_pattern(
|
||||||
|
pattern_string=output_dir_pattern,
|
||||||
|
token_data=token_data_variant_cleaned
|
||||||
|
)
|
||||||
|
final_variant_path = Path(context.output_base_path) / Path(relative_dir_path_str_variant) / Path(output_filename_variant)
|
||||||
|
final_variant_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if final_variant_path.exists() and not overwrite_existing:
|
||||||
|
logger.info(f"Asset '{asset_name_for_log}': Output variant file {final_variant_path} for map '{processed_map_key}' (res: {variant_resolution_key}) exists and overwrite is disabled. Skipping copy.")
|
||||||
|
variant_detail['status'] = 'Organized (Exists, Skipped Copy)'
|
||||||
|
else:
|
||||||
|
shutil.copy2(temp_variant_path, final_variant_path)
|
||||||
|
logger.info(f"Asset '{asset_name_for_log}': Copied variant {temp_variant_path} to {final_variant_path} for map '{processed_map_key}'.")
|
||||||
|
final_output_files.append(str(final_variant_path))
|
||||||
|
variant_detail['status'] = 'Organized'
|
||||||
|
|
||||||
|
variant_detail['final_output_path'] = str(final_variant_path)
|
||||||
|
relative_final_variant_path_str = str(Path(relative_dir_path_str_variant) / Path(output_filename_variant))
|
||||||
|
map_metadata_entry['variant_paths'][variant_resolution_key] = relative_final_variant_path_str
|
||||||
|
processed_any_variant_successfully = True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Asset '{asset_name_for_log}': Failed to copy variant {temp_variant_path} for map key '{processed_map_key}' (res: {variant_resolution_key}). Error: {e}", exc_info=True)
|
||||||
|
context.status_flags['output_organization_error'] = True
|
||||||
|
context.asset_metadata['status'] = "Failed (Output Organization Error - Variant)"
|
||||||
|
variant_detail['status'] = 'Organization Failed'
|
||||||
|
failed_any_variant = True
|
||||||
|
|
||||||
|
# Update parent map detail status based on variant outcomes
|
||||||
|
if failed_any_variant:
|
||||||
|
details['status'] = 'Organization Failed (Variants)'
|
||||||
|
elif processed_any_variant_successfully:
|
||||||
|
# Check if all processable variants were organized
|
||||||
|
all_attempted_organized = True
|
||||||
|
for v_detail in variants:
|
||||||
|
if v_detail.get('temp_path') and not v_detail.get('status', '').startswith('Organized'):
|
||||||
|
all_attempted_organized = False
|
||||||
|
break
|
||||||
|
if all_attempted_organized:
|
||||||
|
details['status'] = 'Organized (All Attempted Variants)'
|
||||||
|
else:
|
||||||
|
details['status'] = 'Partially Organized (Variants)'
|
||||||
|
elif not any(v.get('temp_path') for v in variants): # No variants had temp_paths to begin with
|
||||||
|
details['status'] = 'Processed_With_Variants (No Valid Variants to Organize)'
|
||||||
|
else: # Variants list existed, items had temp_paths, but none were successfully organized (e.g., all skipped due to existing file and no overwrite)
|
||||||
|
details['status'] = 'Organization Skipped (No Variants Copied/Needed)'
|
||||||
|
|
||||||
|
|
||||||
|
else: # Other statuses like 'Skipped', 'Failed', 'Organization Failed' etc.
|
||||||
|
logger.debug(f"Asset '{asset_name_for_log}': Skipping map key '{processed_map_key}' (status: '{map_status}') for organization as it's not 'Processed', 'Processed_No_Variants', or 'Processed_With_Variants'.")
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Asset '{asset_name_for_log}': No processed individual maps to organize.")
|
logger.debug(f"Asset '{asset_name_for_log}': No processed individual maps to organize.")
|
||||||
|
|
||||||
|
|||||||
@ -31,17 +31,17 @@ def get_nearest_power_of_two_downscale(value: int) -> int:
|
|||||||
If the value is already a power of two, it returns the value itself.
|
If the value is already a power of two, it returns the value itself.
|
||||||
Returns 1 if the value is less than 1.
|
Returns 1 if the value is less than 1.
|
||||||
"""
|
"""
|
||||||
if value < 1:
|
if value < 1:
|
||||||
return 1
|
return 1
|
||||||
if is_power_of_two(value):
|
if is_power_of_two(value):
|
||||||
return value
|
return value
|
||||||
# Find the largest power of two strictly less than value,
|
# Find the largest power of two strictly less than value,
|
||||||
# unless value itself is POT.
|
# unless value itself is POT.
|
||||||
# (1 << (value.bit_length() - 1)) achieves this.
|
# (1 << (value.bit_length() - 1)) achieves this.
|
||||||
# Example: value=7 (0111, bl=3), 1<<2 = 4.
|
# Example: value=7 (0111, bl=3), 1<<2 = 4.
|
||||||
# Example: value=8 (1000, bl=4), 1<<3 = 8.
|
# Example: value=8 (1000, bl=4), 1<<3 = 8.
|
||||||
# Example: value=9 (1001, bl=4), 1<<3 = 8.
|
# Example: value=9 (1001, bl=4), 1<<3 = 8.
|
||||||
return 1 << (value.bit_length() - 1)
|
return 1 << (value.bit_length() - 1)
|
||||||
# --- Dimension Calculation ---
|
# --- Dimension Calculation ---
|
||||||
|
|
||||||
def calculate_target_dimensions(
|
def calculate_target_dimensions(
|
||||||
@ -184,10 +184,12 @@ def calculate_image_stats(image_data: np.ndarray) -> Optional[Dict]:
|
|||||||
stats["min"] = float(np.min(data_float))
|
stats["min"] = float(np.min(data_float))
|
||||||
stats["max"] = float(np.max(data_float))
|
stats["max"] = float(np.max(data_float))
|
||||||
stats["mean"] = float(np.mean(data_float))
|
stats["mean"] = float(np.mean(data_float))
|
||||||
|
stats["median"] = float(np.median(data_float))
|
||||||
elif len(data_float.shape) == 3: # Color (H, W, C)
|
elif len(data_float.shape) == 3: # Color (H, W, C)
|
||||||
stats["min"] = [float(v) for v in np.min(data_float, axis=(0, 1))]
|
stats["min"] = [float(v) for v in np.min(data_float, axis=(0, 1))]
|
||||||
stats["max"] = [float(v) for v in np.max(data_float, axis=(0, 1))]
|
stats["max"] = [float(v) for v in np.max(data_float, axis=(0, 1))]
|
||||||
stats["mean"] = [float(v) for v in np.mean(data_float, axis=(0, 1))]
|
stats["mean"] = [float(v) for v in np.mean(data_float, axis=(0, 1))]
|
||||||
|
stats["median"] = [float(v) for v in np.median(data_float, axis=(0, 1))]
|
||||||
else:
|
else:
|
||||||
return None # Unsupported shape
|
return None # Unsupported shape
|
||||||
return stats
|
return stats
|
||||||
@ -235,46 +237,67 @@ def normalize_aspect_ratio_change(original_width: int, original_height: int, res
|
|||||||
if abs(output_width - 1.0) < epsilon: output_width = 1
|
if abs(output_width - 1.0) < epsilon: output_width = 1
|
||||||
if abs(output_height - 1.0) < epsilon: output_height = 1
|
if abs(output_height - 1.0) < epsilon: output_height = 1
|
||||||
|
|
||||||
|
# Helper to format the number part
|
||||||
|
def format_value(val, dec):
|
||||||
|
# Multiply by 10^decimals, convert to int to keep trailing zeros in effect
|
||||||
|
# e.g. val=1.1, dec=2 -> 1.1 * 100 = 110
|
||||||
|
# e.g. val=1.0, dec=2 -> 1.0 * 100 = 100 (though this might become "1" if it's exactly 1.0 before this)
|
||||||
|
# The existing logic already handles output_width/height being 1.0 to produce "EVEN" or skip a component.
|
||||||
|
# This formatting is for when output_width/height is NOT 1.0.
|
||||||
|
return str(int(round(val * (10**dec))))
|
||||||
|
|
||||||
if abs(output_width - output_height) < epsilon: # Handles original square or aspect maintained
|
if abs(output_width - output_height) < epsilon: # Handles original square or aspect maintained
|
||||||
output = "EVEN"
|
output = "EVEN"
|
||||||
elif output_width != 1 and abs(output_height - 1.0) < epsilon : # Width changed, height maintained relative to width
|
elif output_width != 1 and abs(output_height - 1.0) < epsilon : # Width changed, height maintained relative to width
|
||||||
output = f"X{str(output_width).replace('.', '')}"
|
output = f"X{format_value(output_width, decimals)}"
|
||||||
elif output_height != 1 and abs(output_width - 1.0) < epsilon: # Height changed, width maintained relative to height
|
elif output_height != 1 and abs(output_width - 1.0) < epsilon: # Height changed, width maintained relative to height
|
||||||
output = f"Y{str(output_height).replace('.', '')}"
|
output = f"Y{format_value(output_height, decimals)}"
|
||||||
else: # Both changed relative to each other
|
else: # Both changed relative to each other
|
||||||
output = f"X{str(output_width).replace('.', '')}Y{str(output_height).replace('.', '')}"
|
output = f"X{format_value(output_width, decimals)}Y{format_value(output_height, decimals)}"
|
||||||
return output
|
return output
|
||||||
|
|
||||||
# --- Image Loading, Conversion, Resizing ---
|
# --- Image Loading, Conversion, Resizing ---
|
||||||
|
|
||||||
def load_image(image_path: Union[str, Path], read_flag: int = cv2.IMREAD_UNCHANGED) -> Optional[np.ndarray]:
|
def load_image(image_path: Union[str, Path], read_flag: int = cv2.IMREAD_UNCHANGED) -> Optional[np.ndarray]:
|
||||||
"""Loads an image from the specified path."""
|
"""Loads an image from the specified path. Converts BGR/BGRA to RGB/RGBA if color."""
|
||||||
try:
|
try:
|
||||||
img = cv2.imread(str(image_path), read_flag)
|
img = cv2.imread(str(image_path), read_flag)
|
||||||
if img is None:
|
if img is None:
|
||||||
# print(f"Warning: Failed to load image: {image_path}") # Optional: for debugging utils
|
# print(f"Warning: Failed to load image: {image_path}") # Optional: for debugging utils
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Ensure RGB/RGBA for color images
|
||||||
|
if len(img.shape) == 3:
|
||||||
|
if img.shape[2] == 4: # BGRA from OpenCV
|
||||||
|
img = cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA)
|
||||||
|
elif img.shape[2] == 3: # BGR from OpenCV
|
||||||
|
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||||||
return img
|
return img
|
||||||
except Exception: # as e:
|
except Exception: # as e:
|
||||||
# print(f"Error loading image {image_path}: {e}") # Optional: for debugging utils
|
# print(f"Error loading image {image_path}: {e}") # Optional: for debugging utils
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def convert_bgr_to_rgb(image: np.ndarray) -> np.ndarray:
|
def convert_bgr_to_rgb(image: np.ndarray) -> np.ndarray:
|
||||||
"""Converts an image from BGR to RGB color space."""
|
"""Converts an image from BGR/BGRA to RGB/RGBA color space."""
|
||||||
if image is None or len(image.shape) < 3:
|
if image is None or len(image.shape) < 3:
|
||||||
return image # Return as is if not a color image or None
|
return image # Return as is if not a color image or None
|
||||||
|
|
||||||
if image.shape[2] == 4: # BGRA
|
if image.shape[2] == 4: # BGRA
|
||||||
return cv2.cvtColor(image, cv2.COLOR_BGRA2RGB)
|
return cv2.cvtColor(image, cv2.COLOR_BGRA2RGBA) # Keep alpha, convert to RGBA
|
||||||
elif image.shape[2] == 3: # BGR
|
elif image.shape[2] == 3: # BGR
|
||||||
return cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
return cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||||
return image # Return as is if not 3 or 4 channels
|
return image # Return as is if not 3 or 4 channels
|
||||||
|
|
||||||
def convert_rgb_to_bgr(image: np.ndarray) -> np.ndarray:
|
def convert_rgb_to_bgr(image: np.ndarray) -> np.ndarray:
|
||||||
"""Converts an image from RGB to BGR color space."""
|
"""Converts an image from RGB/RGBA to BGR/BGRA color space."""
|
||||||
if image is None or len(image.shape) < 3 or image.shape[2] != 3: # Only for 3-channel RGB
|
if image is None or len(image.shape) < 3:
|
||||||
return image # Return as is if not a 3-channel color image or None
|
return image # Return as is if not a color image or None
|
||||||
|
|
||||||
|
if image.shape[2] == 4: # RGBA
|
||||||
|
return cv2.cvtColor(image, cv2.COLOR_RGBA2BGRA)
|
||||||
|
elif image.shape[2] == 3: # RGB
|
||||||
return cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
|
return cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
|
||||||
|
return image # Return as is if not 3 or 4 channels
|
||||||
|
|
||||||
|
|
||||||
def resize_image(image: np.ndarray, target_width: int, target_height: int, interpolation: Optional[int] = None) -> np.ndarray:
|
def resize_image(image: np.ndarray, target_width: int, target_height: int, interpolation: Optional[int] = None) -> np.ndarray:
|
||||||
@ -349,18 +372,19 @@ def save_image(
|
|||||||
elif img_to_save.dtype == np.float16: img_to_save = img_to_save.astype(np.float32)
|
elif img_to_save.dtype == np.float16: img_to_save = img_to_save.astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
# 2. Color Space Conversion (RGB -> BGR)
|
# 2. Color Space Conversion (Internal RGB/RGBA -> BGR/BGRA for OpenCV)
|
||||||
# Typically, OpenCV expects BGR for formats like PNG, JPG. EXR usually expects RGB.
|
# Input `image_data` is assumed to be in RGB/RGBA format (due to `load_image` changes).
|
||||||
# The `convert_to_bgr_before_save` flag controls this.
|
# OpenCV's `imwrite` typically expects BGR/BGRA for formats like PNG, JPG.
|
||||||
# If output_format is exr, this should generally be False.
|
# EXR format usually expects RGB/RGBA.
|
||||||
|
# The `convert_to_bgr_before_save` flag controls this behavior.
|
||||||
current_format = output_format if output_format else path_obj.suffix.lower().lstrip('.')
|
current_format = output_format if output_format else path_obj.suffix.lower().lstrip('.')
|
||||||
|
|
||||||
if convert_to_bgr_before_save and current_format != 'exr':
|
if convert_to_bgr_before_save and current_format != 'exr':
|
||||||
if len(img_to_save.shape) == 3 and img_to_save.shape[2] == 3:
|
# If image is 3-channel (RGB) or 4-channel (RGBA), convert to BGR/BGRA.
|
||||||
img_to_save = convert_rgb_to_bgr(img_to_save)
|
if len(img_to_save.shape) == 3 and (img_to_save.shape[2] == 3 or img_to_save.shape[2] == 4):
|
||||||
# BGRA is handled by OpenCV imwrite for PNGs, no explicit conversion needed if saving as RGBA.
|
img_to_save = convert_rgb_to_bgr(img_to_save) # Handles RGB->BGR and RGBA->BGRA
|
||||||
# If it's 4-channel and not PNG/TIFF with alpha, it might need stripping or specific handling.
|
# If `convert_to_bgr_before_save` is False or format is 'exr',
|
||||||
# For simplicity, this function assumes 3-channel RGB input if BGR conversion is active.
|
# the image (assumed RGB/RGBA) is saved as is.
|
||||||
|
|
||||||
# 3. Save Image
|
# 3. Save Image
|
||||||
try:
|
try:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user