""" Mask Exporter Exports segmentation mask annotations as PNG binary images. Each label gets a separate PNG where filled pixels are the label color and background is transparent. Requires: numpy and Pillow (PIL) """ import os import logging from typing import Optional, Tuple, List from .base import BaseExporter, ExportContext, ExportResult from .cv_utils import ( extract_image_annotations, get_image_dimensions, get_image_filename, build_category_mapping, decode_rle, ) logger = logging.getLogger(__name__) class MaskExporter(BaseExporter): format_name = "mask_png" description = "Segmentation masks as PNG images (requires Pillow)" file_extensions = [".png"] def can_export(self, context: ExportContext) -> Tuple[bool, str]: # Check for Pillow try: from PIL import Image except ImportError: return False, "Pillow (PIL) is required for mask export. Install with: pip install Pillow" has_image_schema = any( s.get("annotation_type") == "image_annotation" for s in context.schemas ) if not has_image_schema: return False, "No image_annotation schema found in config" return True, "" def export(self, context: ExportContext, output_path: str, options: Optional[dict] = None) -> ExportResult: from PIL import Image options = options or {} warnings = [] files_written = [] os.makedirs(output_path, exist_ok=True) category_map = build_category_mapping(context.annotations, context.schemas) # Assign colors to categories default_colors = [ (255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0), (255, 0, 255), (0, 255, 255), (128, 0, 0), (0, 128, 0), (0, 0, 128), (128, 128, 0), ] category_colors = {} for name, idx in category_map.items(): category_colors[name] = default_colors[idx % len(default_colors)] masks_exported = 0 for ann in context.annotations: instance_id = ann.get("instance_id", "") item = context.items.get(instance_id, {}) img_anns = extract_image_annotations(ann) if not img_anns: continue width, height = get_image_dimensions(item) if width <= 0 or height <= 0: # Try to get from mask RLE size for _, objects in img_anns: for obj in objects: if obj.get("type") == "mask" and "rle" in obj: size = obj["rle"].get("size", []) if len(size) == 2: height, width = size break if width > 0: break if width <= 0 or height <= 0: warnings.append(f"No dimensions for {instance_id}, skipping masks") continue file_name = get_image_filename(item) or instance_id raw_stem = os.path.splitext(os.path.basename(file_name))[0] stem = "".join(c if c.isalnum() or c in "-_." else "_" for c in raw_stem) for schema_name, objects in img_anns: for obj in objects: if obj.get("type") != "mask": continue label = obj.get("label", "unknown") rle = obj.get("rle", {}) if not rle.get("counts"): continue mask_data = decode_rle(rle, width, height) color = category_colors.get(label, (255, 255, 255)) # Create RGBA image img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) pixels = img.load() for i, val in enumerate(mask_data): if val: y = i // width x = i % width if x < width and y < height: pixels[x, y] = (color[0], color[1], color[2], 200) safe_label = "".join(c if c.isalnum() or c in "-_." else "_" for c in label) mask_file = os.path.join(output_path, f"{stem}_{safe_label}_mask.png") img.save(mask_file) files_written.append(mask_file) masks_exported += 1 return ExportResult( success=True, format_name=self.format_name, files_written=files_written, warnings=warnings, stats={"num_masks": masks_exported}, )