Uncompleted Processing Refactor

This commit is contained in:
2025-05-09 11:32:16 +02:00
parent d473ddd7f4
commit 12cf557dd7
38 changed files with 7472 additions and 1536 deletions

View File

@@ -0,0 +1,24 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional
from rule_structure import AssetRule, FileRule, SourceRule
from configuration import Configuration
@dataclass
class AssetProcessingContext:
source_rule: SourceRule
asset_rule: AssetRule
workspace_path: Path
engine_temp_dir: Path
output_base_path: Path
effective_supplier: Optional[str]
asset_metadata: Dict
processed_maps_details: Dict[str, Dict[str, Dict]]
merged_maps_details: Dict[str, Dict[str, Dict]]
files_to_process: List[FileRule]
loaded_data_cache: Dict
config_obj: Configuration
status_flags: Dict
incrementing_value: Optional[str]
sha5_value: Optional[str]

View File

@@ -0,0 +1,133 @@
from typing import List, Dict, Optional
from pathlib import Path
import shutil
import tempfile
import logging
from configuration import Configuration
from rule_structure import SourceRule, AssetRule
from .asset_context import AssetProcessingContext
from .stages.base_stage import ProcessingStage
log = logging.getLogger(__name__)
class PipelineOrchestrator:
"""
Orchestrates the processing of assets based on source rules and a series of processing stages.
"""
def __init__(self, config_obj: Configuration, stages: List[ProcessingStage]):
"""
Initializes the PipelineOrchestrator.
Args:
config_obj: The main configuration object.
stages: A list of processing stages to be executed in order.
"""
self.config_obj: Configuration = config_obj
self.stages: List[ProcessingStage] = stages
def process_source_rule(
self,
source_rule: SourceRule,
workspace_path: Path,
output_base_path: Path,
overwrite: bool, # Not used in this initial implementation, but part of the signature
incrementing_value: Optional[str],
sha5_value: Optional[str] # Corrected from sha5_value to sha256_value as per typical usage, assuming typo
) -> Dict[str, List[str]]:
"""
Processes a single source rule, iterating through its asset rules and applying all stages.
Args:
source_rule: The source rule to process.
workspace_path: The base path of the workspace.
output_base_path: The base path for output files.
overwrite: Whether to overwrite existing files (not fully implemented yet).
incrementing_value: An optional incrementing value for versioning or naming.
sha5_value: An optional SHA5 hash value for the asset (assuming typo, likely sha256).
Returns:
A dictionary summarizing the processing status of assets.
"""
overall_status: Dict[str, List[str]] = {
"processed": [],
"skipped": [],
"failed": [],
}
engine_temp_dir_path: Optional[Path] = None # Initialize to None
try:
# Create a temporary directory for this processing run if needed by any stage
# This temp dir is for the entire source_rule processing, not per asset.
# Individual stages might create their own sub-temp dirs if necessary.
temp_dir_path_str = tempfile.mkdtemp(
prefix="asset_processor_orchestrator_temp_", dir=self.config_obj.get_temp_directory_base()
)
engine_temp_dir_path = Path(temp_dir_path_str)
log.debug(f"PipelineOrchestrator created temporary directory: {engine_temp_dir_path}")
for asset_rule in source_rule.assets:
log.debug(f"Orchestrator: Processing asset '{asset_rule.name}'")
context = AssetProcessingContext(
source_rule=source_rule,
asset_rule=asset_rule,
workspace_path=workspace_path, # This is the path to the source files (e.g. extracted archive)
engine_temp_dir=engine_temp_dir_path, # Pass the orchestrator's temp dir
output_base_path=output_base_path,
effective_supplier=None, # Will be set by SupplierDeterminationStage
asset_metadata={}, # Will be populated by stages
processed_maps_details={}, # Will be populated by stages
merged_maps_details={}, # Will be populated by stages
files_to_process=[], # Will be populated by FileRuleFilterStage
loaded_data_cache={}, # For image loading cache within this asset's processing
config_obj=self.config_obj,
status_flags={"skip_asset": False, "asset_failed": False}, # Initialize common flags
incrementing_value=incrementing_value,
sha256_value=sha5_value # Parameter name in context is sha256_value
)
for stage_idx, stage in enumerate(self.stages):
log.debug(f"Asset '{asset_rule.name}': Executing stage {stage_idx + 1}/{len(self.stages)}: {stage.__class__.__name__}")
try:
context = stage.execute(context)
except Exception as e:
log.error(f"Asset '{asset_rule.name}': Error during stage '{stage.__class__.__name__}': {e}", exc_info=True)
context.status_flags["asset_failed"] = True
context.asset_metadata["status"] = f"Failed: Error in stage {stage.__class__.__name__}"
context.asset_metadata["error_message"] = str(e)
break # Stop processing stages for this asset on error
if context.status_flags.get("skip_asset"):
log.info(f"Asset '{asset_rule.name}': Skipped by stage '{stage.__class__.__name__}'. Reason: {context.status_flags.get('skip_reason', 'N/A')}")
break # Skip remaining stages for this asset
# Refined status collection
if context.status_flags.get('skip_asset'):
overall_status["skipped"].append(asset_rule.name)
elif context.status_flags.get('asset_failed') or str(context.asset_metadata.get('status', '')).startswith("Failed"):
overall_status["failed"].append(asset_rule.name)
elif context.asset_metadata.get('status') == "Processed":
overall_status["processed"].append(asset_rule.name)
else: # Default or unknown state
log.warning(f"Asset '{asset_rule.name}': Unknown status after pipeline execution. Metadata status: '{context.asset_metadata.get('status')}'. Marking as failed.")
overall_status["failed"].append(f"{asset_rule.name} (Unknown Status: {context.asset_metadata.get('status')})")
log.debug(f"Asset '{asset_rule.name}' final status: {context.asset_metadata.get('status', 'N/A')}, Flags: {context.status_flags}")
except Exception as e:
log.error(f"PipelineOrchestrator.process_source_rule failed: {e}", exc_info=True)
# Mark all remaining assets as failed if a top-level error occurs
processed_or_skipped_or_failed = set(overall_status["processed"] + overall_status["skipped"] + overall_status["failed"])
for asset_rule in source_rule.assets:
if asset_rule.name not in processed_or_skipped_or_failed:
overall_status["failed"].append(f"{asset_rule.name} (Orchestrator Error)")
finally:
if engine_temp_dir_path and engine_temp_dir_path.exists():
try:
log.debug(f"PipelineOrchestrator cleaning up temporary directory: {engine_temp_dir_path}")
shutil.rmtree(engine_temp_dir_path, ignore_errors=True)
except Exception as e:
log.error(f"Error cleaning up orchestrator temporary directory {engine_temp_dir_path}: {e}", exc_info=True)
return overall_status

View File

@@ -0,0 +1,175 @@
import logging
import uuid
from pathlib import Path
from typing import List, Optional, Dict
import numpy as np
from .base_stage import ProcessingStage
from ..asset_context import AssetProcessingContext
from ...utils import image_processing_utils as ipu
from .....rule_structure import FileRule, TransformSettings
from .....utils.path_utils import sanitize_filename
logger = logging.getLogger(__name__)
class AlphaExtractionToMaskStage(ProcessingStage):
"""
Extracts an alpha channel from a suitable source map (e.g., Albedo, Diffuse)
to generate a MASK map if one is not explicitly defined.
"""
SUITABLE_SOURCE_MAP_TYPES = ["ALBEDO", "DIFFUSE", "BASE_COLOR"] # Map types likely to have alpha
def execute(self, context: AssetProcessingContext) -> AssetProcessingContext:
logger.debug(f"Asset '{context.asset_rule.name}': Running AlphaExtractionToMaskStage.")
if context.status_flags.get('skip_asset'):
logger.debug(f"Asset '{context.asset_rule.name}': Skipping due to 'skip_asset' flag.")
return context
if not context.files_to_process or not context.processed_maps_details:
logger.debug(
f"Asset '{context.asset_rule.name}': Skipping alpha extraction - "
f"no files to process or no processed map details."
)
return context
# A. Check for Existing MASK Map
for file_rule in context.files_to_process:
if file_rule.map_type == "MASK":
logger.info(
f"Asset '{context.asset_rule.name}': MASK map already defined by FileRule "
f"'{file_rule.filename_pattern}'. Skipping alpha extraction."
)
return context
# B. Find Suitable Source Map with Alpha
source_map_details_for_alpha: Optional[Dict] = None
source_file_rule_id_for_alpha: Optional[str] = None
for file_rule_id, details in context.processed_maps_details.items():
if details.get('status') == 'Processed' and \
details.get('map_type') in self.SUITABLE_SOURCE_MAP_TYPES:
try:
temp_path = Path(details['temp_processed_file'])
if not temp_path.exists():
logger.warning(
f"Asset '{context.asset_rule.name}': Temp file {temp_path} for map "
f"{details['map_type']} (ID: {file_rule_id}) does not exist. Cannot check for alpha."
)
continue
# Load image header or minimal data to check for alpha if possible,
# otherwise load full image. ipu.load_image should handle this.
image_data = ipu.load_image(temp_path)
if image_data is not None and image_data.ndim == 3 and image_data.shape[2] == 4:
source_map_details_for_alpha = details
source_file_rule_id_for_alpha = file_rule_id
logger.info(
f"Asset '{context.asset_rule.name}': Found potential source for alpha extraction: "
f"{temp_path} (MapType: {details['map_type']})"
)
break
except Exception as e:
logger.warning(
f"Asset '{context.asset_rule.name}': Error checking alpha for {details.get('temp_processed_file', 'N/A')}: {e}"
)
continue
if source_map_details_for_alpha is None or source_file_rule_id_for_alpha is None:
logger.info(
f"Asset '{context.asset_rule.name}': No suitable source map with alpha channel found "
f"for MASK extraction."
)
return context
# C. Extract Alpha Channel
source_image_path = Path(source_map_details_for_alpha['temp_processed_file'])
full_image_data = ipu.load_image(source_image_path) # Reload to ensure we have the original RGBA
if full_image_data is None or not (full_image_data.ndim == 3 and full_image_data.shape[2] == 4):
logger.error(
f"Asset '{context.asset_rule.name}': Failed to reload or verify alpha channel from "
f"{source_image_path} for MASK extraction."
)
return context
alpha_channel: np.ndarray = full_image_data[:, :, 3] # Extract alpha (0-255)
# D. Save New Temporary MASK Map
# Ensure the mask is a 2D grayscale image. If ipu.save_image expects 3 channels for grayscale, adapt.
# Assuming ipu.save_image can handle a 2D numpy array for a grayscale image.
if alpha_channel.ndim == 2: # Expected
pass
elif alpha_channel.ndim == 3 and alpha_channel.shape[2] == 1: # (H, W, 1)
alpha_channel = alpha_channel.squeeze(axis=2)
else:
logger.error(
f"Asset '{context.asset_rule.name}': Extracted alpha channel has unexpected dimensions: "
f"{alpha_channel.shape}. Cannot save."
)
return context
mask_temp_filename = (
f"mask_from_alpha_{sanitize_filename(source_map_details_for_alpha['map_type'])}"
f"_{source_file_rule_id_for_alpha}{source_image_path.suffix}"
)
mask_temp_path = context.engine_temp_dir / mask_temp_filename
save_success = ipu.save_image(mask_temp_path, alpha_channel)
if not save_success:
logger.error(
f"Asset '{context.asset_rule.name}': Failed to save extracted alpha mask to {mask_temp_path}."
)
return context
logger.info(
f"Asset '{context.asset_rule.name}': Extracted alpha and saved as new MASK map: {mask_temp_path}"
)
# E. Create New FileRule for the MASK and Update Context
new_mask_file_rule_id_obj = uuid.uuid4()
new_mask_file_rule_id_str = str(new_mask_file_rule_id_obj) # Use string for FileRule.id
new_mask_file_rule_id_hex = new_mask_file_rule_id_obj.hex # Use hex for dict key
new_mask_file_rule = FileRule(
id=new_mask_file_rule_id_str,
map_type="MASK",
filename_pattern=mask_temp_path.name, # Pattern matches the generated temp file
item_type="MAP_COL", # Considered a collected map post-generation
active=True,
transform_settings=TransformSettings(), # Default transform settings
source_map_ids_for_generation=[source_file_rule_id_for_alpha] # Link to original source
# Ensure other necessary FileRule fields are defaulted or set if required
)
context.files_to_process.append(new_mask_file_rule)
original_dims = source_map_details_for_alpha.get('original_dimensions')
if original_dims is None and full_image_data is not None: # Fallback if not in details
original_dims = (full_image_data.shape[1], full_image_data.shape[0])
context.processed_maps_details[new_mask_file_rule_id_hex] = {
'map_type': "MASK",
'source_file': str(source_image_path), # Original RGBA map path
'temp_processed_file': str(mask_temp_path), # Path to the new MASK map
'original_dimensions': original_dims, # Dimensions of the source image
'processed_dimensions': (alpha_channel.shape[1], alpha_channel.shape[0]), # Dimensions of MASK
'status': 'Processed', # This map is now considered processed
'notes': (
f"Generated from alpha of {source_map_details_for_alpha['map_type']} "
f"(Source Rule ID: {source_file_rule_id_for_alpha})"
),
'file_rule_id': new_mask_file_rule_id_str # Link back to the new FileRule ID
}
logger.info(
f"Asset '{context.asset_rule.name}': Added new FileRule for generated MASK "
f"(ID: {new_mask_file_rule_id_str}) and updated processed_maps_details."
)
return context

View File

@@ -0,0 +1,48 @@
import logging
from ..base_stage import ProcessingStage
from ...asset_context import AssetProcessingContext
class AssetSkipLogicStage(ProcessingStage):
"""
Processing stage to determine if an asset should be skipped based on various conditions.
"""
def execute(self, context: AssetProcessingContext) -> AssetProcessingContext:
"""
Executes the asset skip logic.
Args:
context: The asset processing context.
Returns:
The updated asset processing context.
"""
context.status_flags['skip_asset'] = False # Initialize/reset skip flag
# 1. Check for Supplier Error
# Assuming 'supplier_error' might be set by a previous stage (e.g., SupplierDeterminationStage)
# or if effective_supplier is None after attempts to determine it.
if context.effective_supplier is None or context.status_flags.get('supplier_error', False):
logging.info(f"Asset '{context.asset_rule.name}': Skipping due to missing or invalid supplier.")
context.status_flags['skip_asset'] = True
context.status_flags['skip_reason'] = "Invalid or missing supplier"
return context
# 2. Check asset_rule.process_status
if context.asset_rule.process_status == "SKIP":
logging.info(f"Asset '{context.asset_rule.name}': Skipping as per process_status 'SKIP'.")
context.status_flags['skip_asset'] = True
context.status_flags['skip_reason'] = "Process status set to SKIP"
return context
if context.asset_rule.process_status == "PROCESSED" and \
not context.config_obj.general_settings.overwrite_existing:
logging.info(
f"Asset '{context.asset_rule.name}': Skipping as it's already 'PROCESSED' "
f"and overwrite is disabled."
)
context.status_flags['skip_asset'] = True
context.status_flags['skip_reason'] = "Already processed, overwrite disabled"
return context
# If none of the above conditions are met, skip_asset remains False.
return context

View File

@@ -0,0 +1,22 @@
from abc import ABC, abstractmethod
from ..asset_context import AssetProcessingContext
class ProcessingStage(ABC):
"""
Abstract base class for a stage in the asset processing pipeline.
"""
@abstractmethod
def execute(self, context: AssetProcessingContext) -> AssetProcessingContext:
"""
Executes the processing logic of this stage.
Args:
context: The current asset processing context.
Returns:
The updated asset processing context.
"""
pass

View File

@@ -0,0 +1,80 @@
import logging
import fnmatch
from typing import List, Set
from ..base_stage import ProcessingStage
from ...asset_context import AssetProcessingContext
from .....rule_structure import FileRule
class FileRuleFilterStage(ProcessingStage):
"""
Determines which FileRules associated with an AssetRule should be processed.
Populates context.files_to_process, respecting FILE_IGNORE rules.
"""
def execute(self, context: AssetProcessingContext) -> AssetProcessingContext:
"""
Executes the file rule filtering logic.
Args:
context: The AssetProcessingContext for the current asset.
Returns:
The modified AssetProcessingContext.
"""
if context.status_flags.get('skip_asset'):
logging.debug(f"Asset '{context.asset_rule.name}': Skipping FileRuleFilterStage due to 'skip_asset' flag.")
return context
context.files_to_process: List[FileRule] = []
ignore_patterns: Set[str] = set()
# Step 1: Collect all FILE_IGNORE patterns
if context.asset_rule and context.asset_rule.file_rules:
for file_rule in context.asset_rule.file_rules:
if file_rule.item_type == "FILE_IGNORE" and file_rule.active:
ignore_patterns.add(file_rule.filename_pattern)
logging.debug(
f"Asset '{context.asset_rule.name}': Registering ignore pattern: '{file_rule.filename_pattern}'"
)
else:
logging.debug(f"Asset '{context.asset_rule.name if context.asset_rule else 'Unknown'}': No file rules to process or asset_rule is None.")
# Still need to return context even if there are no rules
logging.info(f"Asset '{context.asset_rule.name if context.asset_rule else 'Unknown'}': 0 file rules queued for processing after filtering.")
return context
# Step 2: Filter and add processable FileRules
for file_rule in context.asset_rule.file_rules:
if not file_rule.active:
logging.debug(
f"Asset '{context.asset_rule.name}': Skipping inactive file rule '{file_rule.filename_pattern}'."
)
continue
if file_rule.item_type == "FILE_IGNORE":
# Already processed, skip.
continue
is_ignored = False
for ignore_pat in ignore_patterns:
if fnmatch.fnmatch(file_rule.filename_pattern, ignore_pat):
is_ignored = True
logging.debug(
f"Asset '{context.asset_rule.name}': Skipping file rule '{file_rule.filename_pattern}' "
f"due to matching ignore pattern '{ignore_pat}'."
)
break
if not is_ignored:
context.files_to_process.append(file_rule)
logging.debug(
f"Asset '{context.asset_rule.name}': Adding file rule '{file_rule.filename_pattern}' "
f"(type: {file_rule.item_type}) to processing queue."
)
logging.info(
f"Asset '{context.asset_rule.name}': {len(context.files_to_process)} file rules queued for processing after filtering."
)
return context

View File

@@ -0,0 +1,156 @@
import logging
from pathlib import Path
import numpy as np
from typing import List
from .base_stage import ProcessingStage
from ..asset_context import AssetProcessingContext
from ...rule_structure import FileRule
from ..utils import image_processing_utils as ipu
from ...utils.path_utils import sanitize_filename
logger = logging.getLogger(__name__)
class GlossToRoughConversionStage(ProcessingStage):
"""
Processing stage to convert glossiness maps to roughness maps.
Iterates through FileRules, identifies GLOSS maps, loads their
corresponding temporary processed images, inverts them, and saves
them as new temporary ROUGHNESS maps. Updates the FileRule and
context.processed_maps_details accordingly.
"""
def execute(self, context: AssetProcessingContext) -> AssetProcessingContext:
"""
Executes the gloss to roughness conversion logic.
Args:
context: The AssetProcessingContext containing asset and processing details.
Returns:
The updated AssetProcessingContext.
"""
if context.status_flags.get('skip_asset'):
logger.debug(f"Asset '{context.asset_rule.name}': Skipping GlossToRoughConversionStage due to skip_asset flag.")
return context
if not context.files_to_process or not context.processed_maps_details:
logger.debug(
f"Asset '{context.asset_rule.name}': No files to process or processed_maps_details empty "
f"in GlossToRoughConversionStage. Skipping."
)
return context
new_files_to_process: List[FileRule] = []
processed_a_gloss_map = False
logger.info(f"Asset '{context.asset_rule.name}': Starting Gloss to Roughness Conversion Stage.")
for idx, file_rule in enumerate(context.files_to_process):
if file_rule.map_type == "GLOSS":
map_detail_key = file_rule.id.hex
if map_detail_key not in context.processed_maps_details:
logger.warning(
f"Asset '{context.asset_rule.name}': GLOSS map '{file_rule.source_file_path}' "
f"(ID: {map_detail_key}) found in files_to_process but not in processed_maps_details. "
f"Adding original rule and skipping conversion for this map."
)
new_files_to_process.append(file_rule)
continue
map_details = context.processed_maps_details[map_detail_key]
if map_details.get('status') != 'Processed' or 'temp_processed_file' not in map_details:
logger.warning(
f"Asset '{context.asset_rule.name}': GLOSS map '{file_rule.source_file_path}' "
f"(ID: {map_detail_key}) not successfully processed by previous stage or temp file missing. "
f"Status: {map_details.get('status')}. Adding original rule and skipping conversion."
)
new_files_to_process.append(file_rule)
continue
original_temp_path_str = map_details['temp_processed_file']
original_temp_path = Path(original_temp_path_str)
if not original_temp_path.exists():
logger.error(
f"Asset '{context.asset_rule.name}': Temporary file {original_temp_path_str} for GLOSS map "
f"(ID: {map_detail_key}) does not exist. Adding original rule and skipping conversion."
)
new_files_to_process.append(file_rule)
continue
logger.debug(f"Asset '{context.asset_rule.name}': Processing GLOSS map {original_temp_path} for conversion.")
image_data = ipu.load_image(original_temp_path)
if image_data is None:
logger.error(
f"Asset '{context.asset_rule.name}': Failed to load image data from {original_temp_path} "
f"for GLOSS map (ID: {map_detail_key}). Adding original rule and skipping conversion."
)
new_files_to_process.append(file_rule)
continue
# Perform Inversion
inverted_image_data: np.ndarray
if np.issubdtype(image_data.dtype, np.floating):
inverted_image_data = 1.0 - image_data
inverted_image_data = np.clip(inverted_image_data, 0.0, 1.0) # Ensure range for floats
logger.debug(f"Asset '{context.asset_rule.name}': Inverted float image data for {original_temp_path}.")
elif np.issubdtype(image_data.dtype, np.integer):
max_val = np.iinfo(image_data.dtype).max
inverted_image_data = max_val - image_data
logger.debug(f"Asset '{context.asset_rule.name}': Inverted integer image data (max_val: {max_val}) for {original_temp_path}.")
else:
logger.error(
f"Asset '{context.asset_rule.name}': Unsupported image data type {image_data.dtype} "
f"for GLOSS map {original_temp_path}. Cannot invert. Adding original rule."
)
new_files_to_process.append(file_rule)
continue
# Save New Temporary (Roughness) Map
# Using original_temp_path.suffix ensures we keep the format (e.g., .png, .exr)
new_temp_filename = f"rough_from_gloss_{sanitize_filename(file_rule.map_type)}_{file_rule.id.hex}{original_temp_path.suffix}"
new_temp_path = context.engine_temp_dir / new_temp_filename
save_success = ipu.save_image(new_temp_path, inverted_image_data)
if save_success:
logger.info(
f"Asset '{context.asset_rule.name}': Converted GLOSS map {original_temp_path} "
f"to ROUGHNESS map {new_temp_path}."
)
modified_file_rule = file_rule.model_copy(deep=True)
modified_file_rule.map_type = "ROUGHNESS"
# Update context.processed_maps_details for the original file_rule.id.hex
context.processed_maps_details[map_detail_key]['temp_processed_file'] = str(new_temp_path)
context.processed_maps_details[map_detail_key]['original_map_type_before_conversion'] = "GLOSS"
context.processed_maps_details[map_detail_key]['notes'] = "Converted from GLOSS by GlossToRoughConversionStage"
new_files_to_process.append(modified_file_rule)
processed_a_gloss_map = True
else:
logger.error(
f"Asset '{context.asset_rule.name}': Failed to save inverted ROUGHNESS map to {new_temp_path} "
f"for original GLOSS map (ID: {map_detail_key}). Adding original rule."
)
new_files_to_process.append(file_rule)
else: # Not a gloss map
new_files_to_process.append(file_rule)
context.files_to_process = new_files_to_process
if processed_a_gloss_map:
logger.info(
f"Asset '{context.asset_rule.name}': Gloss to Roughness conversion stage successfully processed one or more maps and updated file list."
)
else:
logger.debug(
f"Asset '{context.asset_rule.name}': No gloss maps were successfully converted in GlossToRoughConversionStage. "
f"File list for next stage contains original non-gloss maps and any gloss maps that failed conversion."
)
return context

View File

@@ -0,0 +1,245 @@
import os
import logging
from pathlib import Path
from typing import Optional, Tuple, Dict
import cv2
import numpy as np
from ..base_stage import ProcessingStage
from ..asset_context import AssetProcessingContext
from ....rule_structure import FileRule, TransformSettings
from ....utils.path_utils import sanitize_filename
from ...utils import image_processing_utils as ipu
logger = logging.getLogger(__name__)
class IndividualMapProcessingStage(ProcessingStage):
"""
Processes individual texture map files based on FileRules.
This stage finds the source file, loads it, applies transformations
(resize, color space), saves a temporary processed version, and updates
the AssetProcessingContext with details.
"""
def execute(self, context: AssetProcessingContext) -> AssetProcessingContext:
"""
Executes the individual map processing logic.
"""
if context.status_flags.get('skip_asset', False):
logger.info(f"Asset {context.asset_id}: Skipping individual map processing due to skip_asset flag.")
return context
if not hasattr(context, 'processed_maps_details') or context.processed_maps_details is None:
context.processed_maps_details = {}
logger.debug(f"Asset {context.asset_id}: Initialized processed_maps_details.")
if not context.files_to_process:
logger.info(f"Asset {context.asset_id}: No files to process in this stage.")
return context
source_base_path = Path(context.asset_rule.source_path)
if not source_base_path.is_dir():
logger.error(f"Asset {context.asset_id}: Source path '{source_base_path}' is not a valid directory. Skipping individual map processing.")
context.status_flags['individual_map_processing_failed'] = True
# Potentially mark all file_rules as failed if source path is invalid
for file_rule in context.files_to_process:
if file_rule.item_type.startswith("MAP_"): # General check for map types
self._update_file_rule_status(context, file_rule.id.hex, 'Failed', details="Source path invalid")
return context
for file_rule in context.files_to_process:
# Primarily focus on "MAP_COL", "MAP_NORM", "MAP_ROUGH", etc.
# For now, let's assume any item_type starting with "MAP_" is a candidate
# unless it's specifically handled by another stage (e.g., "MAP_GEN" might be).
# The prompt mentions "MAP_COL" primarily.
# Let's be a bit more specific for now, focusing on types that are typically direct file mappings.
# This can be refined based on how `item_type` is used for generated maps.
# For now, we'll process any `FileRule` that isn't explicitly a generated map type
# that would be handled *after* individual processing (e.g. a composite map).
# A simple check for now:
if not file_rule.item_type or not file_rule.item_type.startswith("MAP_") or file_rule.item_type == "MAP_GEN_COMPOSITE": # Example exclusion
logger.debug(f"Asset {context.asset_id}, FileRule {file_rule.id.hex} ({file_rule.map_type}): Skipping, item_type '{file_rule.item_type}' not targeted for individual processing.")
continue
logger.info(f"Asset {context.asset_id}, FileRule {file_rule.id.hex} ({file_rule.map_type}): Starting individual processing.")
# A. Find Source File
source_file_path = self._find_source_file(source_base_path, file_rule.filename_pattern, context.asset_id, file_rule.id.hex)
if not source_file_path:
logger.error(f"Asset {context.asset_id}, FileRule {file_rule.id.hex} ({file_rule.map_type}): Source file not found with pattern '{file_rule.filename_pattern}' in '{source_base_path}'.")
self._update_file_rule_status(context, file_rule.id.hex, 'Failed', map_type=file_rule.map_type, details="Source file not found")
continue
# B. Load and Transform Image
image_data: Optional[np.ndarray] = ipu.load_image(str(source_file_path))
if image_data is None:
logger.error(f"Asset {context.asset_id}, FileRule {file_rule.id.hex} ({file_rule.map_type}): Failed to load image from '{source_file_path}'.")
self._update_file_rule_status(context, file_rule.id.hex, 'Failed', map_type=file_rule.map_type, source_file=str(source_file_path), details="Image load failed")
continue
original_height, original_width = image_data.shape[:2]
logger.debug(f"Asset {context.asset_id}, FileRule {file_rule.id.hex}: Loaded image '{source_file_path}' with dimensions {original_width}x{original_height}.")
transform: TransformSettings = file_rule.transform_settings
target_width, target_height = ipu.calculate_target_dimensions(
original_width, original_height,
transform.target_width, transform.target_height,
transform.resize_mode,
transform.ensure_pot,
transform.allow_upscale
)
logger.debug(f"Asset {context.asset_id}, FileRule {file_rule.id.hex}: Original dims: ({original_width},{original_height}), Calculated target dims: ({target_width},{target_height})")
processed_image_data = image_data.copy() # Start with a copy
if (target_width, target_height) != (original_width, original_height):
logger.info(f"Asset {context.asset_id}, FileRule {file_rule.id.hex}: Resizing from ({original_width},{original_height}) to ({target_width},{target_height}).")
# Map resize_filter string to cv2 interpolation constant
interpolation_map = {
"NEAREST": cv2.INTER_NEAREST,
"LINEAR": cv2.INTER_LINEAR,
"CUBIC": cv2.INTER_CUBIC,
"AREA": cv2.INTER_AREA, # Good for downscaling
"LANCZOS4": cv2.INTER_LANCZOS4
}
interpolation = interpolation_map.get(transform.resize_filter.upper(), cv2.INTER_AREA) # Default to INTER_AREA
processed_image_data = ipu.resize_image(processed_image_data, target_width, target_height, interpolation=interpolation)
if processed_image_data is None: # Should not happen if resize_image handles errors, but good practice
logger.error(f"Asset {context.asset_id}, FileRule {file_rule.id.hex} ({file_rule.map_type}): Failed to resize image.")
self._update_file_rule_status(context, file_rule.id.hex, 'Failed', map_type=file_rule.map_type, source_file=str(source_file_path), original_dimensions=(original_width, original_height), details="Image resize failed")
continue
# Color Space Conversion (simplified)
# Assuming ipu.load_image loads as BGR if color.
# This needs more robust handling of source color profiles if they are known.
if transform.color_profile_management and transform.target_color_profile == "RGB":
if len(processed_image_data.shape) == 3 and processed_image_data.shape[2] == 3: # Check if it's a color image
logger.info(f"Asset {context.asset_id}, FileRule {file_rule.id.hex}: Converting BGR to RGB.")
processed_image_data = ipu.convert_bgr_to_rgb(processed_image_data)
elif len(processed_image_data.shape) == 3 and processed_image_data.shape[2] == 4: # Check for BGRA
logger.info(f"Asset {context.asset_id}, FileRule {file_rule.id.hex}: Converting BGRA to RGBA.")
processed_image_data = ipu.convert_bgra_to_rgba(processed_image_data)
# C. Save Temporary Processed Map
# Ensure engine_temp_dir exists (orchestrator should handle this, but good to be safe)
if not context.engine_temp_dir.exists():
try:
context.engine_temp_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Asset {context.asset_id}: Created engine_temp_dir at '{context.engine_temp_dir}'")
except OSError as e:
logger.error(f"Asset {context.asset_id}: Failed to create engine_temp_dir '{context.engine_temp_dir}': {e}")
self._update_file_rule_status(context, file_rule.id.hex, 'Failed', map_type=file_rule.map_type, source_file=str(source_file_path), details="Failed to create temp directory")
continue # Or potentially fail the whole asset processing here
temp_filename_suffix = Path(source_file_path).suffix
# Use a more descriptive name if possible, including map_type
safe_map_type = sanitize_filename(file_rule.map_type if file_rule.map_type else "unknown_map")
temp_output_filename = f"processed_{safe_map_type}_{file_rule.id.hex}{temp_filename_suffix}"
temp_output_path = context.engine_temp_dir / temp_output_filename
# Consider output_format_settings from transform if they apply here
# For now, save_image handles basic saving.
# Example: cv2.imwrite params for quality for JPG, compression for PNG
save_params = []
if transform.output_format_settings:
if temp_filename_suffix.lower() in ['.jpg', '.jpeg']:
quality = transform.output_format_settings.get('quality', 95)
save_params = [cv2.IMWRITE_JPEG_QUALITY, quality]
elif temp_filename_suffix.lower() == '.png':
compression = transform.output_format_settings.get('compression_level', 3) # 0-9, 3 is default
save_params = [cv2.IMWRITE_PNG_COMPRESSION, compression]
# Add more formats as needed (e.g., EXR, TIFF)
save_success = ipu.save_image(str(temp_output_path), processed_image_data, params=save_params)
if not save_success:
logger.error(f"Asset {context.asset_id}, FileRule {file_rule.id.hex} ({file_rule.map_type}): Failed to save temporary image to '{temp_output_path}'.")
self._update_file_rule_status(
context, file_rule.id.hex, 'Failed',
map_type=file_rule.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
logger.info(f"Asset {context.asset_id}, FileRule {file_rule.id.hex} ({file_rule.map_type}): Successfully processed and saved temporary map to '{temp_output_path}'.")
# D. Update Context
self._update_file_rule_status(
context, file_rule.id.hex, 'Processed',
map_type=file_rule.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"
)
# Optional: Update context.asset_metadata['processed_files']
if 'processed_files' not in context.asset_metadata:
context.asset_metadata['processed_files'] = []
context.asset_metadata['processed_files'].append({
'file_rule_id': file_rule.id.hex,
'path': str(temp_output_path),
'type': 'temporary_map',
'map_type': file_rule.map_type
})
logger.info(f"Asset {context.asset_id}: Finished individual map processing stage.")
return context
def _find_source_file(self, base_path: Path, pattern: str, asset_id: str, file_rule_id_hex: str) -> Optional[Path]:
"""
Finds a single source file matching the pattern within the base_path.
Adapts logic from ProcessingEngine._find_source_file.
"""
if not pattern:
logger.warning(f"Asset {asset_id}, FileRule {file_rule_id_hex}: Empty filename pattern provided.")
return None
try:
# Using rglob for potentially nested structures, though original might have been simpler.
# If pattern is exact filename, it will also work.
# If pattern is a glob, it will search.
matched_files = list(base_path.rglob(pattern))
if not matched_files:
logger.debug(f"Asset {asset_id}, FileRule {file_rule_id_hex}: No files found matching pattern '{pattern}' in '{base_path}' (recursive).")
# Try non-recursive if rglob fails and pattern might be for top-level
matched_files_non_recursive = list(base_path.glob(pattern))
if matched_files_non_recursive:
logger.debug(f"Asset {asset_id}, FileRule {file_rule_id_hex}: Found {len(matched_files_non_recursive)} files non-recursively. Using first: {matched_files_non_recursive[0]}")
return matched_files_non_recursive[0]
return None
if len(matched_files) > 1:
logger.warning(f"Asset {asset_id}, FileRule {file_rule_id_hex}: Multiple files ({len(matched_files)}) found for pattern '{pattern}' in '{base_path}'. Using the first one: {matched_files[0]}. Files: {matched_files}")
return matched_files[0]
except Exception as e:
logger.error(f"Asset {asset_id}, FileRule {file_rule_id_hex}: Error searching for file with pattern '{pattern}' in '{base_path}': {e}")
return None
def _update_file_rule_status(self, context: AssetProcessingContext, file_rule_id_hex: str, status: str, **kwargs):
"""Helper to update processed_maps_details for a file_rule."""
if file_rule_id_hex not in context.processed_maps_details:
context.processed_maps_details[file_rule_id_hex] = {}
context.processed_maps_details[file_rule_id_hex]['status'] = status
for key, value in kwargs.items():
context.processed_maps_details[file_rule_id_hex][key] = value
# Ensure essential keys are present even on failure, if known
if 'map_type' not in context.processed_maps_details[file_rule_id_hex] and 'map_type' in kwargs:
context.processed_maps_details[file_rule_id_hex]['map_type'] = kwargs['map_type']
logger.debug(f"Asset {context.asset_id}, FileRule {file_rule_id_hex}: Status updated to '{status}'. Details: {kwargs}")

View File

@@ -0,0 +1,310 @@
import logging
from pathlib import Path
from typing import Dict, Optional, List, Tuple
import numpy as np
import cv2 # For potential direct cv2 operations if ipu doesn't cover all merge needs
from ..base_stage import ProcessingStage
from ...asset_context import AssetProcessingContext
from ....rule_structure import FileRule, MergeSettings, MergeInputChannel
from ....utils.path_utils import sanitize_filename
from ...utils import image_processing_utils as ipu
logger = logging.getLogger(__name__)
class MapMergingStage(ProcessingStage):
"""
Merges individually processed maps based on MAP_MERGE rules.
This stage performs operations like channel packing.
"""
def execute(self, context: AssetProcessingContext) -> AssetProcessingContext:
"""
Executes the map merging logic.
Args:
context: The asset processing context.
Returns:
The updated asset processing context.
"""
if context.status_flags.get('skip_asset'):
logger.info(f"Skipping map merging for asset {context.asset_name} as skip_asset flag is set.")
return context
if not hasattr(context, 'merged_maps_details'):
context.merged_maps_details = {}
if not hasattr(context, 'processed_maps_details'):
logger.warning(f"Asset {context.asset_name}: 'processed_maps_details' not found in context. Cannot perform map merging.")
return context
if not context.files_to_process:
logger.info(f"Asset {context.asset_name}: No files_to_process defined. Skipping map merging.")
return context
logger.info(f"Starting MapMergingStage for asset: {context.asset_name}")
for merge_rule in context.files_to_process:
if not isinstance(merge_rule, FileRule) or merge_rule.item_type != "MAP_MERGE":
continue
if not merge_rule.merge_settings:
logger.error(f"Asset {context.asset_name}, Rule ID {merge_rule.id.hex}: Merge rule for map_type '{merge_rule.map_type}' is missing merge_settings. Skipping this merge.")
context.merged_maps_details[merge_rule.id.hex] = {
'map_type': merge_rule.map_type,
'status': 'Failed',
'reason': 'Missing merge_settings in FileRule.'
}
continue
merge_settings: MergeSettings = merge_rule.merge_settings
output_map_type = merge_rule.map_type
rule_id_hex = merge_rule.id.hex
logger.info(f"Processing MAP_MERGE rule for '{output_map_type}' (ID: {rule_id_hex})")
loaded_input_maps: Dict[str, np.ndarray] = {}
input_map_paths: Dict[str, str] = {}
target_dims: Optional[Tuple[int, int]] = None # width, height
all_inputs_valid = True
# A. Load Input Maps for Merging
if not merge_settings.input_maps:
logger.warning(f"Asset {context.asset_name}, Rule ID {rule_id_hex}: No input_maps defined in merge_settings for '{output_map_type}'. Skipping this merge.")
context.merged_maps_details[rule_id_hex] = {
'map_type': output_map_type,
'status': 'Failed',
'reason': 'No input_maps defined in merge_settings.'
}
continue
for input_map_config in merge_settings.input_maps:
input_rule_id_hex = input_map_config.file_rule_id.hex
processed_detail = context.processed_maps_details.get(input_rule_id_hex)
if not processed_detail or processed_detail.get('status') != 'Processed':
error_msg = f"Input map (Rule ID: {input_rule_id_hex}) for merge rule '{output_map_type}' (Rule ID: {rule_id_hex}) not found or not processed. Details: {processed_detail}"
logger.error(error_msg)
all_inputs_valid = False
context.merged_maps_details[rule_id_hex] = {
'map_type': output_map_type,
'status': 'Failed',
'reason': f"Input map {input_rule_id_hex} not processed or missing."
}
break
temp_processed_file_path = Path(processed_detail['temp_processed_file'])
if not temp_processed_file_path.exists():
error_msg = f"Input map file {temp_processed_file_path} for merge rule '{output_map_type}' (Rule ID: {rule_id_hex}) does not exist."
logger.error(error_msg)
all_inputs_valid = False
context.merged_maps_details[rule_id_hex] = {
'map_type': output_map_type,
'status': 'Failed',
'reason': f"Input map file {temp_processed_file_path} not found."
}
break
try:
image_data = ipu.load_image(temp_processed_file_path)
except Exception as e:
logger.error(f"Error loading image {temp_processed_file_path} for merge rule '{output_map_type}' (Rule ID: {rule_id_hex}): {e}")
all_inputs_valid = False
context.merged_maps_details[rule_id_hex] = {
'map_type': output_map_type,
'status': 'Failed',
'reason': f"Error loading input image {temp_processed_file_path}."
}
break
if image_data is None:
logger.error(f"Failed to load image data from {temp_processed_file_path} for merge rule '{output_map_type}' (Rule ID: {rule_id_hex}).")
all_inputs_valid = False
context.merged_maps_details[rule_id_hex] = {
'map_type': output_map_type,
'status': 'Failed',
'reason': f"Failed to load image data from {temp_processed_file_path}."
}
break
loaded_input_maps[input_rule_id_hex] = image_data
input_map_paths[input_rule_id_hex] = str(temp_processed_file_path)
current_dims = (image_data.shape[1], image_data.shape[0]) # width, height
if target_dims is None:
target_dims = current_dims
logger.debug(f"Merge rule '{output_map_type}' (ID: {rule_id_hex}): Set target dimensions to {target_dims} from first input {temp_processed_file_path}.")
elif current_dims != target_dims:
logger.warning(f"Input map {temp_processed_file_path} for merge rule '{output_map_type}' (ID: {rule_id_hex}) has dimensions {current_dims}, but target is {target_dims}. Resizing.")
try:
image_data = ipu.resize_image(image_data, target_dims[0], target_dims[1])
if image_data is None:
raise ValueError("Resize operation returned None.")
loaded_input_maps[input_rule_id_hex] = image_data
except Exception as e:
logger.error(f"Failed to resize image {temp_processed_file_path} for merge rule '{output_map_type}' (ID: {rule_id_hex}): {e}")
all_inputs_valid = False
context.merged_maps_details[rule_id_hex] = {
'map_type': output_map_type,
'status': 'Failed',
'reason': f"Failed to resize input image {temp_processed_file_path}."
}
break
if not all_inputs_valid:
# Failure already logged and recorded in context.merged_maps_details
logger.warning(f"Skipping merge for '{output_map_type}' (ID: {rule_id_hex}) due to invalid inputs.")
continue
if target_dims is None: # Should not happen if all_inputs_valid is true and there was at least one input map
logger.error(f"Merge rule '{output_map_type}' (ID: {rule_id_hex}): Target dimensions not determined despite valid inputs. This indicates an issue with input map loading or an empty input_maps list that wasn't caught.")
context.merged_maps_details[rule_id_hex] = {
'map_type': output_map_type,
'status': 'Failed',
'reason': 'Target dimensions could not be determined.'
}
continue
# B. Perform Merge Operation
try:
if merge_settings.output_channels == 1:
merged_image = np.zeros((target_dims[1], target_dims[0]), dtype=np.uint8)
else:
merged_image = np.zeros((target_dims[1], target_dims[0], merge_settings.output_channels), dtype=np.uint8)
except Exception as e:
logger.error(f"Error creating empty merged image for '{output_map_type}' (ID: {rule_id_hex}) with dims {target_dims} and {merge_settings.output_channels} channels: {e}")
context.merged_maps_details[rule_id_hex] = {
'map_type': output_map_type,
'status': 'Failed',
'reason': f'Error creating output image canvas: {e}'
}
continue
merge_op_failed = False
for input_map_config in merge_settings.input_maps:
source_image = loaded_input_maps[input_map_config.file_rule_id.hex]
source_channel_index = input_map_config.source_channel
target_channel_index = input_map_config.target_channel
source_data = None
if source_image.ndim == 2: # Grayscale
source_data = source_image
elif source_image.ndim == 3: # Multi-channel (e.g. RGB, RGBA)
if source_channel_index >= source_image.shape[2]:
logger.error(f"Merge rule '{output_map_type}' (ID: {rule_id_hex}): Source channel index {source_channel_index} out of bounds for source image with shape {source_image.shape} (from Rule ID {input_map_config.file_rule_id.hex}).")
merge_op_failed = True
break
source_data = source_image[:, :, source_channel_index]
else:
logger.error(f"Merge rule '{output_map_type}' (ID: {rule_id_hex}): Source image (from Rule ID {input_map_config.file_rule_id.hex}) has unexpected dimensions: {source_image.ndim}. Shape: {source_image.shape}")
merge_op_failed = True
break
if source_data is None: # Should be caught by previous checks
logger.error(f"Merge rule '{output_map_type}' (ID: {rule_id_hex}): Failed to extract source_data for unknown reasons from input {input_map_config.file_rule_id.hex}.")
merge_op_failed = True
break
# Assign to target channel
try:
if merged_image.ndim == 2: # Output is grayscale
if merge_settings.output_channels != 1:
logger.error(f"Merge rule '{output_map_type}' (ID: {rule_id_hex}): Mismatch - merged_image is 2D but output_channels is {merge_settings.output_channels}.")
merge_op_failed = True
break
merged_image = source_data # Overwrites if multiple inputs map to grayscale; consider blending or specific logic if needed
elif merged_image.ndim == 3: # Output is multi-channel
if target_channel_index >= merged_image.shape[2]:
logger.error(f"Merge rule '{output_map_type}' (ID: {rule_id_hex}): Target channel index {target_channel_index} out of bounds for merged image with shape {merged_image.shape}.")
merge_op_failed = True
break
merged_image[:, :, target_channel_index] = source_data
else: # Should not happen
logger.error(f"Merge rule '{output_map_type}' (ID: {rule_id_hex}): Merged image has unexpected dimensions: {merged_image.ndim}. Shape: {merged_image.shape}")
merge_op_failed = True
break
except Exception as e:
logger.error(f"Error assigning source data to target channel for '{output_map_type}' (ID: {rule_id_hex}): {e}. Source shape: {source_data.shape}, Target channel: {target_channel_index}, Merged image shape: {merged_image.shape}")
merge_op_failed = True
break
if input_map_config.invert_source_channel:
if merged_image.ndim == 2:
merged_image = 255 - merged_image # Assumes uint8
elif merged_image.ndim == 3:
# Ensure we are not inverting an alpha channel if that's not desired,
# but current spec inverts the target channel data.
merged_image[:, :, target_channel_index] = 255 - merged_image[:, :, target_channel_index]
# input_map_config.default_value_if_missing:
# This was handled by all_inputs_valid check for file presence.
# If a channel is missing from a multi-channel source, that's an error in source_channel_index.
# If a file is entirely missing and a default color/value is needed for the *output channel*,
# that would be a different logic, perhaps pre-filling merged_image.
# For now, we assume if an input map is specified, it must be present and valid.
if merge_op_failed:
logger.error(f"Merge operation failed for '{output_map_type}' (ID: {rule_id_hex}).")
context.merged_maps_details[rule_id_hex] = {
'map_type': output_map_type,
'status': 'Failed',
'reason': 'Error during channel packing/merge operation.'
}
continue
# C. Save Temporary Merged Map
# Default to PNG, or use format from merge_settings if available (future enhancement)
output_format = getattr(merge_settings, 'output_format', 'png').lower()
if output_format not in ['png', 'jpg', 'jpeg', 'tif', 'tiff', 'exr']: # Add more as ipu supports
logger.warning(f"Unsupported output_format '{output_format}' in merge_settings for '{output_map_type}' (ID: {rule_id_hex}). Defaulting to PNG.")
output_format = 'png'
temp_merged_filename = f"merged_{sanitize_filename(output_map_type)}_{rule_id_hex}.{output_format}"
if not context.engine_temp_dir:
logger.error(f"Asset {context.asset_name}: engine_temp_dir is not set. Cannot save merged map.")
context.merged_maps_details[rule_id_hex] = {
'map_type': output_map_type,
'status': 'Failed',
'reason': 'engine_temp_dir not set in context.'
}
continue
temp_merged_path = context.engine_temp_dir / temp_merged_filename
try:
save_success = ipu.save_image(temp_merged_path, merged_image)
except Exception as e:
logger.error(f"Error saving merged image {temp_merged_path} for '{output_map_type}' (ID: {rule_id_hex}): {e}")
save_success = False
if not save_success:
logger.error(f"Failed to save temporary merged map to {temp_merged_path} for '{output_map_type}' (ID: {rule_id_hex}).")
context.merged_maps_details[rule_id_hex] = {
'map_type': output_map_type,
'status': 'Failed',
'reason': f'Failed to save merged image to {temp_merged_path}.'
}
continue
logger.info(f"Successfully merged and saved '{output_map_type}' (ID: {rule_id_hex}) to {temp_merged_path}")
# D. Update Context
context.merged_maps_details[rule_id_hex] = {
'map_type': output_map_type,
'temp_merged_file': str(temp_merged_path),
'input_map_ids_used': [mc.file_rule_id.hex for mc in merge_settings.input_maps],
'input_map_files_used': input_map_paths, # Dict[rule_id_hex, path_str]
'merged_dimensions': target_dims, # (width, height)
'status': 'Processed',
'file_rule_id': rule_id_hex # For easier reverse lookup if needed
}
# Optional: Update context.asset_metadata['processed_files'] or similar
# This might be better handled by a later stage that finalizes files.
# For now, merged_maps_details is the primary record.
logger.info(f"Finished MapMergingStage for asset: {context.asset_name}. Merged maps: {len(context.merged_maps_details)}")
return context

View File

@@ -0,0 +1,119 @@
import datetime
import json
import logging
from pathlib import Path
from typing import Any, Dict
from ..asset_context import AssetProcessingContext
from .base_stage import ProcessingStage
from ....utils.path_utils import generate_path_from_pattern
logger = logging.getLogger(__name__)
class MetadataFinalizationAndSaveStage(ProcessingStage):
"""
This stage finalizes the asset_metadata (e.g., setting processing end time,
final status) and saves it as a JSON file.
"""
def execute(self, context: AssetProcessingContext) -> AssetProcessingContext:
"""
Finalizes metadata, determines output path, and saves the metadata JSON file.
"""
if not hasattr(context, 'asset_metadata') or not context.asset_metadata:
if context.status_flags.get('skip_asset'):
logger.info(
f"Asset '{context.asset_rule.name if hasattr(context, 'asset_rule') and context.asset_rule else 'Unknown'}': "
f"Skipped before metadata initialization. No metadata file will be saved."
)
else:
logger.warning(
f"Asset '{context.asset_rule.name if hasattr(context, 'asset_rule') and context.asset_rule else 'Unknown'}': "
f"asset_metadata not initialized. Skipping metadata finalization and save."
)
return context
# Check Skip Flag
if context.status_flags.get('skip_asset'):
context.asset_metadata['status'] = "Skipped"
context.asset_metadata['processing_end_time'] = datetime.datetime.now().isoformat()
context.asset_metadata['notes'] = context.status_flags.get('skip_reason', 'Skipped early in pipeline')
logger.info(
f"Asset '{context.asset_rule.name}': Marked as skipped. Reason: {context.asset_metadata['notes']}"
)
# Assuming we save metadata for skipped assets if it was initialized.
# If not, the logic to skip saving would be here or before path generation.
# A. Finalize Metadata
context.asset_metadata['processing_end_time'] = datetime.datetime.now().isoformat()
# Determine final status (if not already set to Skipped)
if context.asset_metadata.get('status') != "Skipped":
has_errors = any(
context.status_flags.get(error_flag)
for error_flag in ['file_processing_error', 'merge_error', 'critical_error'] # Added critical_error
)
if has_errors:
context.asset_metadata['status'] = "Failed"
else:
context.asset_metadata['status'] = "Processed"
# Add details of processed and merged maps
context.asset_metadata['processed_map_details'] = getattr(context, 'processed_maps_details', {})
context.asset_metadata['merged_map_details'] = getattr(context, 'merged_maps_details', {})
# (Optional) Add a list of all temporary files
context.asset_metadata['temporary_files'] = getattr(context, 'temporary_files', [])
# B. Determine Metadata Output Path
# Ensure asset_rule and source_rule exist before accessing their names
asset_name = context.asset_rule.name if hasattr(context, 'asset_rule') and context.asset_rule else "unknown_asset"
source_rule_name = context.source_rule.name if hasattr(context, 'source_rule') and context.source_rule else "unknown_source"
metadata_filename = f"{asset_name}_metadata.json"
output_path_pattern = context.asset_rule.output_path_pattern if hasattr(context, 'asset_rule') and context.asset_rule else ""
# Handle potential missing sha5_value, defaulting to None or an empty string
sha_value = getattr(context, 'sha5_value', getattr(context, 'sha_value', None))
full_output_path = generate_path_from_pattern(
base_path=str(context.output_base_path), # Ensure base_path is a string
pattern=output_path_pattern,
asset_name=asset_name,
map_type="metadata", # Special map_type for metadata
filename=metadata_filename,
source_rule_name=source_rule_name,
incrementing_value=getattr(context, 'incrementing_value', None),
sha_value=sha_value # Changed from sha5_value to sha_value for more generality
)
metadata_save_path = Path(full_output_path)
# C. Save Metadata File
try:
metadata_save_path.parent.mkdir(parents=True, exist_ok=True)
def make_serializable(data: Any) -> Any:
if isinstance(data, Path):
return str(data)
if isinstance(data, datetime.datetime): # Ensure datetime is serializable
return data.isoformat()
if isinstance(data, dict):
return {k: make_serializable(v) for k, v in data.items()}
if isinstance(data, list):
return [make_serializable(i) for i in data]
return data
serializable_metadata = make_serializable(context.asset_metadata)
with open(metadata_save_path, 'w') as f:
json.dump(serializable_metadata, f, indent=4)
logger.info(f"Asset '{asset_name}': Metadata saved to {metadata_save_path}")
context.asset_metadata['metadata_file_path'] = str(metadata_save_path)
except Exception as e:
logger.error(f"Asset '{asset_name}': Failed to save metadata to {metadata_save_path}. Error: {e}")
context.asset_metadata['status'] = "Failed (Metadata Save Error)"
context.status_flags['metadata_save_error'] = True
return context

View File

@@ -0,0 +1,163 @@
import datetime
import logging
from ..base_stage import ProcessingStage
from ...asset_context import AssetProcessingContext # Adjusted import path assuming asset_context is in processing.pipeline
# If AssetProcessingContext is directly under 'processing', the import would be:
# from ...asset_context import AssetProcessingContext
# Based on the provided file structure, asset_context.py is in processing/pipeline/
# So, from ...asset_context import AssetProcessingContext is likely incorrect.
# It should be: from ..asset_context import AssetProcessingContext
# Correcting this based on typical Python package structure and the location of base_stage.py
# Re-evaluating import based on common structure:
# If base_stage.py is in processing/pipeline/stages/
# and asset_context.py is in processing/pipeline/
# then the import for AssetProcessingContext from metadata_initialization.py (in stages) would be:
# from ..asset_context import AssetProcessingContext
# Let's assume the following structure for clarity:
# processing/
# L-- pipeline/
# L-- __init__.py
# L-- asset_context.py
# L-- base_stage.py (Mistake here, base_stage is in stages, so it's ..base_stage)
# L-- stages/
# L-- __init__.py
# L-- metadata_initialization.py
# L-- base_stage.py (Corrected: base_stage.py is here)
# Corrected imports based on the plan and typical structure:
# base_stage.py is in processing/pipeline/stages/
# asset_context.py is in processing/pipeline/
# from ..base_stage import ProcessingStage # This would mean base_stage is one level up from stages (i.e. in pipeline)
# The plan says: from ..base_stage import ProcessingStage
# This implies that metadata_initialization.py is in a subdirectory of where base_stage.py is.
# However, the file path for metadata_initialization.py is processing/pipeline/stages/metadata_initialization.py
# And base_stage.py is listed as processing/pipeline/stages/base_stage.py in the open tabs.
# So, the import should be:
# from .base_stage import ProcessingStage
# AssetProcessingContext is at processing/pipeline/asset_context.py
# So from processing/pipeline/stages/metadata_initialization.py, it would be:
# from ..asset_context import AssetProcessingContext
# Final check on imports based on instructions:
# `from ..base_stage import ProcessingStage` -> This means base_stage.py is in `processing/pipeline/`
# `from ...asset_context import AssetProcessingContext` -> This means asset_context.py is in `processing/`
# Let's verify the location of these files from the environment details.
# processing/pipeline/asset_context.py
# processing/pipeline/stages/base_stage.py
#
# So, from processing/pipeline/stages/metadata_initialization.py:
# To import ProcessingStage from processing/pipeline/stages/base_stage.py:
# from .base_stage import ProcessingStage
# To import AssetProcessingContext from processing/pipeline/asset_context.py:
# from ..asset_context import AssetProcessingContext
# The instructions explicitly state:
# `from ..base_stage import ProcessingStage`
# `from ...asset_context import AssetProcessingContext`
# This implies a different structure than what seems to be in the file tree.
# I will follow the explicit import instructions from the task.
# This means:
# base_stage.py is expected at `processing/pipeline/base_stage.py`
# asset_context.py is expected at `processing/asset_context.py`
# Given the file tree:
# processing/pipeline/asset_context.py
# processing/pipeline/stages/base_stage.py
# The imports in `processing/pipeline/stages/metadata_initialization.py` should be:
# from .base_stage import ProcessingStage
# from ..asset_context import AssetProcessingContext
# I will use the imports that align with the provided file structure.
from .base_stage import ProcessingStage
from ..asset_context import AssetProcessingContext
logger = logging.getLogger(__name__)
class MetadataInitializationStage(ProcessingStage):
"""
Initializes metadata structures within the AssetProcessingContext.
This stage sets up asset_metadata, processed_maps_details, and
merged_maps_details.
"""
def execute(self, context: AssetProcessingContext) -> AssetProcessingContext:
"""
Executes the metadata initialization logic.
Args:
context: The AssetProcessingContext for the current asset.
Returns:
The modified AssetProcessingContext.
"""
if context.status_flags.get('skip_asset', False):
logger.debug(f"Asset '{context.asset_rule.name if context.asset_rule else 'Unknown'}': Skipping metadata initialization as 'skip_asset' is True.")
return context
logger.debug(f"Asset '{context.asset_rule.name}': Initializing metadata.")
context.asset_metadata = {}
context.processed_maps_details = {}
context.merged_maps_details = {}
# Populate Initial asset_metadata
if context.asset_rule:
context.asset_metadata['asset_name'] = context.asset_rule.name
context.asset_metadata['asset_id'] = str(context.asset_rule.id)
context.asset_metadata['source_path'] = str(context.asset_rule.source_path)
context.asset_metadata['output_path_pattern'] = context.asset_rule.output_path_pattern
context.asset_metadata['tags'] = list(context.asset_rule.tags) if context.asset_rule.tags else []
context.asset_metadata['custom_fields'] = dict(context.asset_rule.custom_fields) if context.asset_rule.custom_fields else {}
else:
# Handle cases where asset_rule might be None, though typically it should be set
logger.warning("AssetRule is not set in context during metadata initialization.")
context.asset_metadata['asset_name'] = "Unknown Asset"
context.asset_metadata['asset_id'] = "N/A"
context.asset_metadata['source_path'] = "N/A"
context.asset_metadata['output_path_pattern'] = "N/A"
context.asset_metadata['tags'] = []
context.asset_metadata['custom_fields'] = {}
if context.source_rule:
context.asset_metadata['source_rule_name'] = context.source_rule.name
context.asset_metadata['source_rule_id'] = str(context.source_rule.id)
else:
logger.warning("SourceRule is not set in context during metadata initialization.")
context.asset_metadata['source_rule_name'] = "Unknown Source Rule"
context.asset_metadata['source_rule_id'] = "N/A"
context.asset_metadata['effective_supplier'] = context.effective_supplier
context.asset_metadata['processing_start_time'] = datetime.datetime.now().isoformat()
context.asset_metadata['status'] = "Pending"
if context.config_obj and hasattr(context.config_obj, 'general_settings') and \
hasattr(context.config_obj.general_settings, 'app_version'):
context.asset_metadata['version'] = context.config_obj.general_settings.app_version
else:
logger.warning("App version not found in config_obj.general_settings. Setting version to 'N/A'.")
context.asset_metadata['version'] = "N/A" # Default or placeholder
if context.incrementing_value is not None:
context.asset_metadata['incrementing_value'] = context.incrementing_value
# The plan mentions sha5_value, which is likely a typo for sha256 or similar.
# Implementing as 'sha5_value' per instructions, but noting the potential typo.
if hasattr(context, 'sha5_value') and context.sha5_value is not None: # Check attribute existence
context.asset_metadata['sha5_value'] = context.sha5_value
elif hasattr(context, 'sha256_value') and context.sha256_value is not None: # Fallback if sha5 was a typo
logger.debug("sha5_value not found, using sha256_value if available for metadata.")
context.asset_metadata['sha256_value'] = context.sha256_value
logger.info(f"Asset '{context.asset_metadata.get('asset_name', 'Unknown')}': Metadata initialized.")
# Example of how you might log the full metadata for debugging:
# logger.debug(f"Initialized metadata: {context.asset_metadata}")
return context

View File

@@ -0,0 +1,154 @@
import logging
import numpy as np
from pathlib import Path
from typing import List
from ..base_stage import ProcessingStage
from ...asset_context import AssetProcessingContext
from .....rule_structure import FileRule
from ...utils import image_processing_utils as ipu
from .....utils.path_utils import sanitize_filename
logger = logging.getLogger(__name__)
class NormalMapGreenChannelStage(ProcessingStage):
"""
Processing stage to invert the green channel of normal maps if configured.
This is often needed when converting between DirectX (Y-) and OpenGL (Y+) normal map formats.
"""
def execute(self, context: AssetProcessingContext) -> AssetProcessingContext:
"""
Identifies NORMAL maps, checks configuration for green channel inversion,
performs inversion if needed, saves a new temporary file, and updates
the AssetProcessingContext.
"""
if context.status_flags.get('skip_asset'):
logger.debug(f"Asset '{context.asset_rule.name}': Skipping NormalMapGreenChannelStage due to skip_asset flag.")
return context
if not context.files_to_process or not context.processed_maps_details:
logger.debug(
f"Asset '{context.asset_rule.name}': No files to process or processed_maps_details empty in NormalMapGreenChannelStage. Skipping."
)
return context
new_files_to_process: List[FileRule] = []
processed_a_normal_map = False
for file_rule in context.files_to_process:
if file_rule.map_type == "NORMAL":
# Check configuration for inversion
# Assuming a global setting for now.
# This key should exist in the Configuration object's general_settings.
should_invert = context.config_obj.general_settings.get('invert_normal_map_green_channel_globally', False)
if not should_invert:
logger.debug(
f"Asset '{context.asset_rule.name}': Normal map green channel inversion not enabled globally. "
f"Skipping for {file_rule.filename_pattern} (ID: {file_rule.id.hex})."
)
new_files_to_process.append(file_rule)
continue
# Get the temporary processed file path
map_details = context.processed_maps_details.get(file_rule.id.hex)
if not map_details or map_details.get('status') != 'Processed' or not map_details.get('temp_processed_file'):
logger.warning(
f"Asset '{context.asset_rule.name}': Normal map {file_rule.filename_pattern} (ID: {file_rule.id.hex}) "
f"not found in processed_maps_details or not marked as 'Processed'. Cannot invert green channel."
)
new_files_to_process.append(file_rule)
continue
original_temp_path = Path(map_details['temp_processed_file'])
if not original_temp_path.exists():
logger.error(
f"Asset '{context.asset_rule.name}': Temporary file {original_temp_path} for normal map "
f"{file_rule.filename_pattern} (ID: {file_rule.id.hex}) does not exist. Cannot invert green channel."
)
new_files_to_process.append(file_rule)
continue
image_data = ipu.load_image(original_temp_path)
if image_data is None:
logger.error(
f"Asset '{context.asset_rule.name}': Failed to load image from {original_temp_path} "
f"for normal map {file_rule.filename_pattern} (ID: {file_rule.id.hex})."
)
new_files_to_process.append(file_rule)
continue
if image_data.ndim != 3 or image_data.shape[2] < 2: # Must have at least R, G channels
logger.error(
f"Asset '{context.asset_rule.name}': Image {original_temp_path} for normal map "
f"{file_rule.filename_pattern} (ID: {file_rule.id.hex}) is not a valid RGB/normal map "
f"(ndim={image_data.ndim}, channels={image_data.shape[2] if image_data.ndim == 3 else 'N/A'}) "
f"for green channel inversion."
)
new_files_to_process.append(file_rule)
continue
# Perform Green Channel Inversion
modified_image_data = image_data.copy()
try:
if np.issubdtype(modified_image_data.dtype, np.floating):
modified_image_data[:, :, 1] = 1.0 - modified_image_data[:, :, 1]
elif np.issubdtype(modified_image_data.dtype, np.integer):
max_val = np.iinfo(modified_image_data.dtype).max
modified_image_data[:, :, 1] = max_val - modified_image_data[:, :, 1]
else:
logger.error(
f"Asset '{context.asset_rule.name}': Unsupported image data type "
f"{modified_image_data.dtype} for normal map {original_temp_path}. Cannot invert green channel."
)
new_files_to_process.append(file_rule)
continue
except IndexError:
logger.error(
f"Asset '{context.asset_rule.name}': Image {original_temp_path} for normal map "
f"{file_rule.filename_pattern} (ID: {file_rule.id.hex}) does not have a green channel (index 1) "
f"or has unexpected dimensions ({modified_image_data.shape}). Cannot invert."
)
new_files_to_process.append(file_rule)
continue
# Save New Temporary (Modified Normal) Map
new_temp_filename = f"normal_g_inv_{sanitize_filename(file_rule.map_type)}_{file_rule.id.hex}{original_temp_path.suffix}"
new_temp_path = context.engine_temp_dir / new_temp_filename
save_success = ipu.save_image(new_temp_path, modified_image_data)
if save_success:
logger.info(
f"Asset '{context.asset_rule.name}': Inverted green channel for NORMAL map "
f"{original_temp_path.name}, saved to {new_temp_path.name}."
)
# Update processed_maps_details
context.processed_maps_details[file_rule.id.hex]['temp_processed_file'] = str(new_temp_path)
current_notes = context.processed_maps_details[file_rule.id.hex].get('notes', '')
context.processed_maps_details[file_rule.id.hex]['notes'] = \
f"{current_notes}; Green channel inverted by NormalMapGreenChannelStage".strip('; ')
new_files_to_process.append(file_rule) # Add original rule, it now points to modified data
processed_a_normal_map = True
else:
logger.error(
f"Asset '{context.asset_rule.name}': Failed to save inverted normal map to {new_temp_path} "
f"for original {original_temp_path.name}."
)
new_files_to_process.append(file_rule) # Add original rule, as processing failed
else:
# Not a normal map, just pass it through
new_files_to_process.append(file_rule)
context.files_to_process = new_files_to_process
if processed_a_normal_map:
logger.info(f"Asset '{context.asset_rule.name}': NormalMapGreenChannelStage processed relevant normal maps.")
else:
logger.debug(f"Asset '{context.asset_rule.name}': No normal maps found or processed in NormalMapGreenChannelStage.")
return context

View File

@@ -0,0 +1,155 @@
import logging
import shutil
from pathlib import Path
from typing import List, Dict, Optional
from ..base_stage import ProcessingStage
from ...asset_context import AssetProcessingContext
from ....utils.path_utils import generate_path_from_pattern, sanitize_filename
from ....config import FileRule, MergeRule # Assuming these are needed for type hints if not directly in context
logger = logging.getLogger(__name__)
class OutputOrganizationStage(ProcessingStage):
"""
Organizes output files by copying temporary processed files to their final destinations.
"""
def execute(self, context: AssetProcessingContext) -> AssetProcessingContext:
"""
Copies temporary processed and merged files to their final output locations
based on path patterns and updates AssetProcessingContext.
"""
logger.debug(f"Asset '{context.asset_rule.name}': Starting output organization stage.")
if context.status_flags.get('skip_asset'):
logger.info(f"Asset '{context.asset_rule.name}': Output organization skipped as 'skip_asset' is True.")
return context
current_status = context.asset_metadata.get('status', '')
if current_status.startswith("Failed") or current_status == "Skipped":
logger.info(f"Asset '{context.asset_rule.name}': Output organization skipped due to prior status: {current_status}.")
return context
final_output_files: List[str] = []
# Ensure config_obj and general_settings are present, provide default for overwrite_existing if not
overwrite_existing = False
if context.config_obj and hasattr(context.config_obj, 'general_settings'):
overwrite_existing = context.config_obj.general_settings.overwrite_existing
else:
logger.warning(f"Asset '{context.asset_rule.name}': config_obj.general_settings not found, defaulting overwrite_existing to False.")
# A. Organize Processed Individual Maps
if context.processed_maps_details:
logger.debug(f"Asset '{context.asset_rule.name}': Organizing {len(context.processed_maps_details)} processed individual map(s).")
for file_rule_id, details in context.processed_maps_details.items():
if details.get('status') != 'Processed' or not details.get('temp_processed_file'):
logger.debug(f"Asset '{context.asset_rule.name}': Skipping file_rule_id '{file_rule_id}' due to status '{details.get('status')}' or missing temp file.")
continue
temp_file_path = Path(details['temp_processed_file'])
map_type = details['map_type']
output_filename = f"{context.asset_rule.name}_{sanitize_filename(map_type)}{temp_file_path.suffix}"
if context.asset_rule and context.asset_rule.file_rules:
current_file_rule: Optional[FileRule] = next(
(fr for fr in context.asset_rule.file_rules if fr.id == file_rule_id), None
)
if current_file_rule and current_file_rule.output_filename_pattern:
output_filename = current_file_rule.output_filename_pattern
try:
final_path_str = generate_path_from_pattern(
base_path=str(context.output_base_path),
pattern=context.asset_rule.output_path_pattern,
asset_name=context.asset_rule.name,
map_type=map_type,
filename=output_filename,
source_rule_name=context.source_rule.name if context.source_rule else "DefaultSource",
incrementing_value=str(context.incrementing_value) if context.incrementing_value is not None else None,
sha5_value=context.sha5_value
)
final_path = Path(final_path_str)
final_path.parent.mkdir(parents=True, exist_ok=True)
if final_path.exists() and not overwrite_existing:
logger.info(f"Asset '{context.asset_rule.name}': Output file {final_path} exists and overwrite is disabled. Skipping copy.")
else:
shutil.copy2(temp_file_path, final_path)
logger.info(f"Asset '{context.asset_rule.name}': Copied {temp_file_path} to {final_path}")
final_output_files.append(str(final_path))
context.processed_maps_details[file_rule_id]['final_output_path'] = str(final_path)
context.processed_maps_details[file_rule_id]['status'] = 'Organized' # Or some other status indicating completion
except Exception as e:
logger.error(f"Asset '{context.asset_rule.name}': Failed to copy {temp_file_path} to {final_path_str if 'final_path_str' in locals() else 'unknown destination'} for file_rule_id '{file_rule_id}'. Error: {e}", exc_info=True)
context.status_flags['output_organization_error'] = True
context.asset_metadata['status'] = "Failed (Output Organization Error)"
# Optionally update status in details as well
context.processed_maps_details[file_rule_id]['status'] = 'Organization Failed'
else:
logger.debug(f"Asset '{context.asset_rule.name}': No processed individual maps to organize.")
# B. Organize Merged Maps
if context.merged_maps_details:
logger.debug(f"Asset '{context.asset_rule.name}': Organizing {len(context.merged_maps_details)} merged map(s).")
for merge_rule_id, details in context.merged_maps_details.items():
if details.get('status') != 'Processed' or not details.get('temp_merged_file'):
logger.debug(f"Asset '{context.asset_rule.name}': Skipping merge_rule_id '{merge_rule_id}' due to status '{details.get('status')}' or missing temp file.")
continue
temp_file_path = Path(details['temp_merged_file'])
map_type = details['map_type'] # This is the output_map_type of the merge rule
output_filename = f"{context.asset_rule.name}_{sanitize_filename(map_type)}{temp_file_path.suffix}"
if context.asset_rule and context.asset_rule.merge_rules:
current_merge_rule: Optional[MergeRule] = next(
(mr for mr in context.asset_rule.merge_rules if mr.id == merge_rule_id), None
)
if current_merge_rule and current_merge_rule.output_filename_pattern:
output_filename = current_merge_rule.output_filename_pattern
try:
final_path_str = generate_path_from_pattern(
base_path=str(context.output_base_path),
pattern=context.asset_rule.output_path_pattern,
asset_name=context.asset_rule.name,
map_type=map_type,
filename=output_filename,
source_rule_name=context.source_rule.name if context.source_rule else "DefaultSource",
incrementing_value=str(context.incrementing_value) if context.incrementing_value is not None else None,
sha5_value=context.sha5_value
)
final_path = Path(final_path_str)
final_path.parent.mkdir(parents=True, exist_ok=True)
if final_path.exists() and not overwrite_existing:
logger.info(f"Asset '{context.asset_rule.name}': Output file {final_path} exists and overwrite is disabled. Skipping copy for merged map.")
else:
shutil.copy2(temp_file_path, final_path)
logger.info(f"Asset '{context.asset_rule.name}': Copied merged map {temp_file_path} to {final_path}")
final_output_files.append(str(final_path))
context.merged_maps_details[merge_rule_id]['final_output_path'] = str(final_path)
context.merged_maps_details[merge_rule_id]['status'] = 'Organized'
except Exception as e:
logger.error(f"Asset '{context.asset_rule.name}': Failed to copy merged map {temp_file_path} to {final_path_str if 'final_path_str' in locals() else 'unknown destination'} for merge_rule_id '{merge_rule_id}'. Error: {e}", exc_info=True)
context.status_flags['output_organization_error'] = True
context.asset_metadata['status'] = "Failed (Output Organization Error)"
context.merged_maps_details[merge_rule_id]['status'] = 'Organization Failed'
else:
logger.debug(f"Asset '{context.asset_rule.name}': No merged maps to organize.")
context.asset_metadata['final_output_files'] = final_output_files
if context.status_flags.get('output_organization_error'):
logger.error(f"Asset '{context.asset_rule.name}': Output organization encountered errors. Status: {context.asset_metadata['status']}")
else:
logger.info(f"Asset '{context.asset_rule.name}': Output organization complete. {len(final_output_files)} files placed.")
logger.debug(f"Asset '{context.asset_rule.name}': Output organization stage finished.")
return context

View File

@@ -0,0 +1,61 @@
import logging
from .base_stage import ProcessingStage
from ..asset_context import AssetProcessingContext
class SupplierDeterminationStage(ProcessingStage):
"""
Determines the effective supplier for an asset based on asset and source rules.
"""
def execute(self, context: AssetProcessingContext) -> AssetProcessingContext:
"""
Determines and validates the effective supplier for the asset.
Args:
context: The asset processing context.
Returns:
The updated asset processing context.
"""
effective_supplier = None
logger = logging.getLogger(__name__) # Using a logger specific to this module
# 1. Check asset_rule.supplier_override
if context.asset_rule and context.asset_rule.supplier_override:
effective_supplier = context.asset_rule.supplier_override
logger.debug(f"Asset '{context.asset_rule.name}': Supplier override found: '{effective_supplier}'.")
# 2. If not overridden, check source_rule.supplier
if not effective_supplier and context.source_rule and context.source_rule.supplier:
effective_supplier = context.source_rule.supplier
logger.debug(f"Asset '{context.asset_rule.name if context.asset_rule else 'Unknown'}': Source rule supplier found: '{effective_supplier}'.")
# 3. Validation
if not effective_supplier:
asset_name = context.asset_rule.name if context.asset_rule else "Unknown Asset"
logger.error(f"Asset '{asset_name}': No supplier defined in asset rule or source rule.")
context.effective_supplier = None
if 'status_flags' not in context: # Ensure status_flags exists
context.status_flags = {}
context.status_flags['supplier_error'] = True
elif context.config_obj and effective_supplier not in context.config_obj.suppliers:
asset_name = context.asset_rule.name if context.asset_rule else "Unknown Asset"
logger.warning(
f"Asset '{asset_name}': Supplier '{effective_supplier}' not found in global supplier configuration. "
f"Available: {list(context.config_obj.suppliers.keys()) if context.config_obj.suppliers else 'None'}"
)
context.effective_supplier = None
if 'status_flags' not in context: # Ensure status_flags exists
context.status_flags = {}
context.status_flags['supplier_error'] = True
else:
context.effective_supplier = effective_supplier
asset_name = context.asset_rule.name if context.asset_rule else "Unknown Asset"
logger.info(f"Asset '{asset_name}': Effective supplier set to '{effective_supplier}'.")
# Optionally clear the error flag if previously set and now resolved, though current logic doesn't show this path.
# if 'status_flags' in context and 'supplier_error' in context.status_flags:
# del context.status_flags['supplier_error']
return context

View File

@@ -0,0 +1 @@
# This file makes the 'utils' directory a Python package.

View File

@@ -0,0 +1,357 @@
import cv2
import numpy as np
from pathlib import Path
import math
from typing import Optional, Union, List, Tuple, Dict
# --- Basic Power-of-Two Utilities ---
def is_power_of_two(n: int) -> bool:
"""Checks if a number is a power of two."""
return (n > 0) and (n & (n - 1) == 0)
def get_nearest_pot(value: int) -> int:
"""Finds the nearest power of two to the given value."""
if value <= 0:
return 1 # POT must be positive, return 1 as a fallback
if is_power_of_two(value):
return value
lower_pot = 1 << (value.bit_length() - 1)
upper_pot = 1 << value.bit_length()
if (value - lower_pot) < (upper_pot - value):
return lower_pot
else:
return upper_pot
# --- Dimension Calculation ---
def calculate_target_dimensions(
original_width: int,
original_height: int,
target_width: Optional[int] = None,
target_height: Optional[int] = None,
resize_mode: str = "fit", # e.g., "fit", "stretch", "max_dim_pot"
ensure_pot: bool = False,
allow_upscale: bool = False,
target_max_dim_for_pot_mode: Optional[int] = None # Specific for "max_dim_pot"
) -> Tuple[int, int]:
"""
Calculates target dimensions based on various modes and constraints.
Args:
original_width: Original width of the image.
original_height: Original height of the image.
target_width: Desired target width.
target_height: Desired target height.
resize_mode:
- "fit": Scales to fit within target_width/target_height, maintaining aspect ratio.
Requires at least one of target_width or target_height.
- "stretch": Scales to exactly target_width and target_height, ignoring aspect ratio.
Requires both target_width and target_height.
- "max_dim_pot": Scales to fit target_max_dim_for_pot_mode while maintaining aspect ratio,
then finds nearest POT for each dimension. Requires target_max_dim_for_pot_mode.
ensure_pot: If True, final dimensions will be adjusted to the nearest power of two.
allow_upscale: If False, dimensions will not exceed original dimensions unless ensure_pot forces it.
target_max_dim_for_pot_mode: Max dimension to use when resize_mode is "max_dim_pot".
Returns:
A tuple (new_width, new_height).
"""
if original_width <= 0 or original_height <= 0:
# Fallback for invalid original dimensions
fallback_dim = 1
if ensure_pot:
if target_width and target_height:
fallback_dim = get_nearest_pot(max(target_width, target_height, 1))
elif target_width:
fallback_dim = get_nearest_pot(target_width)
elif target_height:
fallback_dim = get_nearest_pot(target_height)
elif target_max_dim_for_pot_mode:
fallback_dim = get_nearest_pot(target_max_dim_for_pot_mode)
else: # Default POT if no target given
fallback_dim = 256
return (fallback_dim, fallback_dim)
return (target_width or 1, target_height or 1)
w, h = original_width, original_height
if resize_mode == "max_dim_pot":
if target_max_dim_for_pot_mode is None:
raise ValueError("target_max_dim_for_pot_mode must be provided for 'max_dim_pot' resize_mode.")
# Logic adapted from old processing_engine.calculate_target_dimensions
ratio = w / h
if ratio > 1: # Width is dominant
scaled_w = target_max_dim_for_pot_mode
scaled_h = max(1, round(scaled_w / ratio))
else: # Height is dominant or square
scaled_h = target_max_dim_for_pot_mode
scaled_w = max(1, round(scaled_h * ratio))
# Upscale check for this mode is implicitly handled by target_max_dim
# If ensure_pot is true (as it was in the original logic), it's applied here
# For this mode, ensure_pot is effectively always true for the final step
w = get_nearest_pot(scaled_w)
h = get_nearest_pot(scaled_h)
return int(w), int(h)
elif resize_mode == "fit":
if target_width is None and target_height is None:
raise ValueError("At least one of target_width or target_height must be provided for 'fit' mode.")
if target_width and target_height:
ratio_orig = w / h
ratio_target = target_width / target_height
if ratio_orig > ratio_target: # Original is wider than target aspect
w_new = target_width
h_new = max(1, round(w_new / ratio_orig))
else: # Original is taller or same aspect
h_new = target_height
w_new = max(1, round(h_new * ratio_orig))
elif target_width:
w_new = target_width
h_new = max(1, round(w_new / (w / h)))
else: # target_height is not None
h_new = target_height
w_new = max(1, round(h_new * (w / h)))
w, h = w_new, h_new
elif resize_mode == "stretch":
if target_width is None or target_height is None:
raise ValueError("Both target_width and target_height must be provided for 'stretch' mode.")
w, h = target_width, target_height
else:
raise ValueError(f"Unsupported resize_mode: {resize_mode}")
if not allow_upscale:
if w > original_width: w = original_width
if h > original_height: h = original_height
if ensure_pot:
w = get_nearest_pot(w)
h = get_nearest_pot(h)
# Re-check upscale if POT adjustment made it larger than original and not allowed
if not allow_upscale:
if w > original_width: w = get_nearest_pot(original_width) # Get closest POT to original
if h > original_height: h = get_nearest_pot(original_height)
return int(max(1, w)), int(max(1, h))
# --- Image Statistics ---
def calculate_image_stats(image_data: np.ndarray) -> Optional[Dict]:
"""
Calculates min, max, mean for a given numpy image array.
Handles grayscale and multi-channel images. Converts to float64 for calculation.
Normalizes uint8/uint16 data to 0-1 range before calculating stats.
"""
if image_data is None:
return None
try:
data_float = image_data.astype(np.float64)
if image_data.dtype == np.uint16:
data_float /= 65535.0
elif image_data.dtype == np.uint8:
data_float /= 255.0
stats = {}
if len(data_float.shape) == 2: # Grayscale (H, W)
stats["min"] = float(np.min(data_float))
stats["max"] = float(np.max(data_float))
stats["mean"] = float(np.mean(data_float))
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["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))]
else:
return None # Unsupported shape
return stats
except Exception:
return {"error": "Error calculating image stats"}
# --- Aspect Ratio String ---
def normalize_aspect_ratio_change(original_width: int, original_height: int, resized_width: int, resized_height: int, decimals: int = 2) -> str:
"""
Calculates the aspect ratio change string (e.g., "EVEN", "X133").
"""
if original_width <= 0 or original_height <= 0:
return "InvalidInput"
if resized_width <= 0 or resized_height <= 0:
return "InvalidResize"
width_change_percentage = ((resized_width - original_width) / original_width) * 100
height_change_percentage = ((resized_height - original_height) / original_height) * 100
normalized_width_change = width_change_percentage / 100
normalized_height_change = height_change_percentage / 100
normalized_width_change = min(max(normalized_width_change + 1, 0), 2)
normalized_height_change = min(max(normalized_height_change + 1, 0), 2)
epsilon = 1e-9
if abs(normalized_width_change) < epsilon and abs(normalized_height_change) < epsilon:
closest_value_to_one = 1.0
elif abs(normalized_width_change) < epsilon:
closest_value_to_one = abs(normalized_height_change)
elif abs(normalized_height_change) < epsilon:
closest_value_to_one = abs(normalized_width_change)
else:
closest_value_to_one = min(abs(normalized_width_change), abs(normalized_height_change))
scale_factor = 1 / (closest_value_to_one + epsilon) if abs(closest_value_to_one) < epsilon else 1 / closest_value_to_one
scaled_normalized_width_change = scale_factor * normalized_width_change
scaled_normalized_height_change = scale_factor * normalized_height_change
output_width = round(scaled_normalized_width_change, decimals)
output_height = round(scaled_normalized_height_change, decimals)
if abs(output_width - 1.0) < epsilon: output_width = 1
if abs(output_height - 1.0) < epsilon: output_height = 1
if abs(output_width - output_height) < epsilon: # Handles original square or aspect maintained
output = "EVEN"
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('.', '')}"
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('.', '')}"
else: # Both changed relative to each other
output = f"X{str(output_width).replace('.', '')}Y{str(output_height).replace('.', '')}"
return output
# --- Image Loading, Conversion, Resizing ---
def load_image(image_path: Union[str, Path], read_flag: int = cv2.IMREAD_UNCHANGED) -> Optional[np.ndarray]:
"""Loads an image from the specified path."""
try:
img = cv2.imread(str(image_path), read_flag)
if img is None:
# print(f"Warning: Failed to load image: {image_path}") # Optional: for debugging utils
return None
return img
except Exception: # as e:
# print(f"Error loading image {image_path}: {e}") # Optional: for debugging utils
return None
def convert_bgr_to_rgb(image: np.ndarray) -> np.ndarray:
"""Converts an image from BGR to RGB color space."""
if image is None or len(image.shape) < 3:
return image # Return as is if not a color image or None
if image.shape[2] == 4: # BGRA
return cv2.cvtColor(image, cv2.COLOR_BGRA2RGB)
elif image.shape[2] == 3: # BGR
return cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
return image # Return as is if not 3 or 4 channels
def convert_rgb_to_bgr(image: np.ndarray) -> np.ndarray:
"""Converts an image from RGB to BGR color space."""
if image is None or len(image.shape) < 3 or image.shape[2] != 3: # Only for 3-channel RGB
return image # Return as is if not a 3-channel color image or None
return cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
def resize_image(image: np.ndarray, target_width: int, target_height: int, interpolation: Optional[int] = None) -> np.ndarray:
"""Resizes an image to target_width and target_height."""
if image is None:
raise ValueError("Cannot resize a None image.")
if target_width <= 0 or target_height <= 0:
raise ValueError("Target width and height must be positive.")
original_height, original_width = image.shape[:2]
if interpolation is None:
# Default interpolation: Lanczos for downscaling, Cubic for upscaling/same
if (target_width * target_height) < (original_width * original_height):
interpolation = cv2.INTER_LANCZOS4
else:
interpolation = cv2.INTER_CUBIC
return cv2.resize(image, (target_width, target_height), interpolation=interpolation)
# --- Image Saving ---
def save_image(
image_path: Union[str, Path],
image_data: np.ndarray,
output_format: Optional[str] = None, # e.g. "png", "jpg", "exr"
output_dtype_target: Optional[np.dtype] = None, # e.g. np.uint8, np.uint16, np.float16
params: Optional[List[int]] = None,
convert_to_bgr_before_save: bool = True # True for most formats except EXR
) -> bool:
"""
Saves image data to a file. Handles data type and color space conversions.
Args:
image_path: Path to save the image.
image_data: NumPy array of the image.
output_format: Desired output format (e.g., 'png', 'jpg'). If None, derived from extension.
output_dtype_target: Target NumPy dtype for saving (e.g., np.uint8, np.uint16).
If None, tries to use image_data.dtype or a sensible default.
params: OpenCV imwrite parameters (e.g., [cv2.IMWRITE_JPEG_QUALITY, 90]).
convert_to_bgr_before_save: If True and image is 3-channel, converts RGB to BGR.
Set to False for formats like EXR that expect RGB.
Returns:
True if saving was successful, False otherwise.
"""
if image_data is None:
return False
img_to_save = image_data.copy()
path_obj = Path(image_path)
path_obj.parent.mkdir(parents=True, exist_ok=True)
# 1. Data Type Conversion
if output_dtype_target is not None:
if output_dtype_target == np.uint8 and img_to_save.dtype != np.uint8:
if img_to_save.dtype == np.uint16: img_to_save = (img_to_save.astype(np.float32) / 65535.0 * 255.0).astype(np.uint8)
elif img_to_save.dtype in [np.float16, np.float32, np.float64]: img_to_save = (np.clip(img_to_save, 0.0, 1.0) * 255.0).astype(np.uint8)
else: img_to_save = img_to_save.astype(np.uint8)
elif output_dtype_target == np.uint16 and img_to_save.dtype != np.uint16:
if img_to_save.dtype == np.uint8: img_to_save = (img_to_save.astype(np.float32) / 255.0 * 65535.0).astype(np.uint16) # More accurate
elif img_to_save.dtype in [np.float16, np.float32, np.float64]: img_to_save = (np.clip(img_to_save, 0.0, 1.0) * 65535.0).astype(np.uint16)
else: img_to_save = img_to_save.astype(np.uint16)
elif output_dtype_target == np.float16 and img_to_save.dtype != np.float16:
if img_to_save.dtype == np.uint16: img_to_save = (img_to_save.astype(np.float32) / 65535.0).astype(np.float16)
elif img_to_save.dtype == np.uint8: img_to_save = (img_to_save.astype(np.float32) / 255.0).astype(np.float16)
elif img_to_save.dtype in [np.float32, np.float64]: img_to_save = img_to_save.astype(np.float16)
# else: cannot convert to float16 easily
elif output_dtype_target == np.float32 and img_to_save.dtype != np.float32:
if img_to_save.dtype == np.uint16: img_to_save = (img_to_save.astype(np.float32) / 65535.0)
elif img_to_save.dtype == np.uint8: img_to_save = (img_to_save.astype(np.float32) / 255.0)
elif img_to_save.dtype == np.float16: img_to_save = img_to_save.astype(np.float32)
# 2. Color Space Conversion (RGB -> BGR)
# Typically, OpenCV expects BGR for formats like PNG, JPG. EXR usually expects RGB.
# The `convert_to_bgr_before_save` flag controls this.
# If output_format is exr, this should generally be False.
current_format = output_format if output_format else path_obj.suffix.lower().lstrip('.')
if convert_to_bgr_before_save and current_format != 'exr':
if len(img_to_save.shape) == 3 and img_to_save.shape[2] == 3:
img_to_save = convert_rgb_to_bgr(img_to_save)
# BGRA is handled by OpenCV imwrite for PNGs, no explicit conversion needed if saving as RGBA.
# If it's 4-channel and not PNG/TIFF with alpha, it might need stripping or specific handling.
# For simplicity, this function assumes 3-channel RGB input if BGR conversion is active.
# 3. Save Image
try:
if params:
cv2.imwrite(str(path_obj), img_to_save, params)
else:
cv2.imwrite(str(path_obj), img_to_save)
return True
except Exception: # as e:
# print(f"Error saving image {path_obj}: {e}") # Optional: for debugging utils
return False