Uncompleted Processing Refactor
This commit is contained in:
24
processing/pipeline/asset_context.py
Normal file
24
processing/pipeline/asset_context.py
Normal 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]
|
||||
133
processing/pipeline/orchestrator.py
Normal file
133
processing/pipeline/orchestrator.py
Normal 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
|
||||
175
processing/pipeline/stages/alpha_extraction_to_mask.py
Normal file
175
processing/pipeline/stages/alpha_extraction_to_mask.py
Normal 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
|
||||
48
processing/pipeline/stages/asset_skip_logic.py
Normal file
48
processing/pipeline/stages/asset_skip_logic.py
Normal 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
|
||||
22
processing/pipeline/stages/base_stage.py
Normal file
22
processing/pipeline/stages/base_stage.py
Normal 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
|
||||
80
processing/pipeline/stages/file_rule_filter.py
Normal file
80
processing/pipeline/stages/file_rule_filter.py
Normal 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
|
||||
156
processing/pipeline/stages/gloss_to_rough_conversion.py
Normal file
156
processing/pipeline/stages/gloss_to_rough_conversion.py
Normal 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
|
||||
245
processing/pipeline/stages/individual_map_processing.py
Normal file
245
processing/pipeline/stages/individual_map_processing.py
Normal 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}")
|
||||
310
processing/pipeline/stages/map_merging.py
Normal file
310
processing/pipeline/stages/map_merging.py
Normal 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
|
||||
119
processing/pipeline/stages/metadata_finalization_save.py
Normal file
119
processing/pipeline/stages/metadata_finalization_save.py
Normal 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
|
||||
163
processing/pipeline/stages/metadata_initialization.py
Normal file
163
processing/pipeline/stages/metadata_initialization.py
Normal 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
|
||||
154
processing/pipeline/stages/normal_map_green_channel.py
Normal file
154
processing/pipeline/stages/normal_map_green_channel.py
Normal 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
|
||||
155
processing/pipeline/stages/output_organization.py
Normal file
155
processing/pipeline/stages/output_organization.py
Normal 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
|
||||
61
processing/pipeline/stages/supplier_determination.py
Normal file
61
processing/pipeline/stages/supplier_determination.py
Normal 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
|
||||
1
processing/utils/__init__.py
Normal file
1
processing/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# This file makes the 'utils' directory a Python package.
|
||||
357
processing/utils/image_processing_utils.py
Normal file
357
processing/utils/image_processing_utils.py
Normal 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
|
||||
Reference in New Issue
Block a user