This commit is contained in:
2026-03-09 10:54:10 -07:00
parent 07b979e358
commit 041c0ebbdb
7 changed files with 1297 additions and 19 deletions

Binary file not shown.

View File

@@ -4,7 +4,7 @@ import zipfile
import tempfile
import shutil
import numpy as np
from PIL import Image
from PIL import Image, ImageChops
import xml.etree.ElementTree as ET
NUM_LAYERS = 4
@@ -40,14 +40,15 @@ def build_stack_xml(w, h, groups):
)
for layer in group["layers"]:
layer_attrs = {
"name": layer["name"],
"src": layer["src"],
"opacity": "1.0"
}
ET.SubElement(
group_el,
"layer",
{
"name": layer["name"],
"src": layer["src"],
"opacity": "1.0"
}
layer_attrs
)
return ET.tostring(image, encoding="utf-8", xml_declaration=True)
@@ -74,24 +75,22 @@ def build_ora(input_png, output_ora):
group_layers = []
base_path = f"data/layer_{i}.png"
base.save(os.path.join(tmp, base_path))
group_layers.append({
"name": "base",
"src": base_path
})
for j in range(MASKS_PER_LAYER):
mask = noise_mask(w, h, NOISE_SCALES[j])
mask_path = f"data/layer_{i}_mask_{j}.png"
mask.save(os.path.join(tmp, mask_path))
# Apply mask to alpha channel of base image
masked_image = base.copy()
r, g, b, a = masked_image.split()
# Multiply existing alpha by mask
new_alpha = ImageChops.multiply(a, mask)
masked_image.putalpha(new_alpha)
layer_path = f"data/layer_{i}_{j}.png"
masked_image.save(os.path.join(tmp, layer_path))
group_layers.append({
"name": f"mask {j}",
"src": mask_path
"name": f"layer {j}",
"src": layer_path
})
groups.append({

608
tools/ora_edit.py Executable file
View File

@@ -0,0 +1,608 @@
#!/usr/bin/env python3
"""
ORA (OpenRaster) editing utility.
Supports creating ORA files from PNGs, extracting layers as PNGs, and masking
elements with alpha channels.
Commands:
create Create an ORA file from a PNG
mask_element Create a masked layer for an entity in an ORA file
inspect Show the structure of an ORA file
extract_png Extract a layer from an ORA file as a PNG
"""
import argparse
import io
import os
import re
import shutil
import sys
import tempfile
import zipfile
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
from PIL import Image, ImageChops
def parse_stack_xml(ora_path: str) -> ET.Element:
"""Parse stack.xml from an ORA file."""
with zipfile.ZipFile(ora_path, 'r') as zf:
return ET.fromstring(zf.read('stack.xml'))
def get_image_size(root: ET.Element) -> tuple[int, int]:
"""Get image dimensions from stack.xml root element."""
return int(root.get('w', 0)), int(root.get('h', 0))
def get_groups_and_layers(root: ET.Element) -> list[dict[str, Any]]:
"""
Extract groups and their layers from stack.xml.
Returns a list of group dicts with 'name', 'layers', and 'is_group' keys.
"""
groups = []
root_stack = root.find('stack')
if root_stack is None:
return groups
for child in root_stack:
if child.tag == 'stack':
# This is a group
group_name = child.get('name', 'unnamed')
layers = []
for layer in child:
if layer.tag == 'layer':
layers.append({
'name': layer.get('name', 'unnamed'),
'src': layer.get('src', ''),
'opacity': layer.get('opacity', '1.0'),
'visibility': layer.get('visibility', 'visible')
})
groups.append({
'name': group_name,
'is_group': True,
'layers': layers
})
elif child.tag == 'layer':
# This is a standalone layer (base layer)
groups.append({
'name': child.get('name', 'unnamed'),
'is_group': False,
'layers': [{
'name': child.get('name', 'unnamed'),
'src': child.get('src', ''),
'opacity': child.get('opacity', '1.0'),
'visibility': child.get('visibility', 'visible')
}]
})
return groups
def get_next_layer_index(group: dict, entity_name: str) -> int:
"""
Get the next auto-increment index for layers in an entity group.
Looks for pattern: entity_name_N
"""
max_index = -1
pattern = re.compile(rf'^{re.escape(entity_name)}_(\d+)$')
for layer in group['layers']:
match = pattern.match(layer['name'])
if match:
max_index = max(max_index, int(match.group(1)))
return max_index + 1
def find_entity_group(groups: list, entity_name: str) -> dict | None:
"""Find an existing entity group by name."""
for group in groups:
if group.get('is_group') and group['name'] == entity_name:
return group
return None
def find_layer(groups: list, layer_name: str) -> tuple[dict, dict] | None:
"""
Find a layer by name across all groups.
Returns (group, layer) tuple or None.
"""
for group in groups:
for layer in group['layers']:
if layer['name'] == layer_name:
return group, layer
return None
def extract_layer_image(ora_path: str, layer_src: str) -> Image.Image:
"""Extract a layer image from the ORA file."""
with zipfile.ZipFile(ora_path, 'r') as zf:
image_data = zf.read(layer_src)
return Image.open(io.BytesIO(image_data)).convert('RGBA')
def apply_mask(source_image: Image.Image, mask_image: Image.Image) -> Image.Image:
"""
Apply a mask as the alpha channel of an image.
Mask can be grayscale or RGB; will be converted to grayscale for alpha.
"""
# Ensure mask is grayscale
if mask_image.mode != 'L':
mask_image = mask_image.convert('L')
# Resize mask to match source if needed
if mask_image.size != source_image.size:
mask_image = mask_image.resize(source_image.size, Image.Resampling.BILINEAR)
# Apply mask to alpha channel
result = source_image.copy()
r, g, b, a = result.split()
new_alpha = ImageChops.multiply(a, mask_image)
result.putalpha(new_alpha)
return result
def build_stack_xml(width: int, height: int, groups: list[dict]) -> bytes:
"""Build stack.xml content from group structure."""
image = ET.Element('image', {
'version': '0.0.3',
'w': str(width),
'h': str(height)
})
root_stack = ET.SubElement(image, 'stack')
for group in groups:
if group.get('is_group'):
# Create group element
group_el = ET.SubElement(root_stack, 'stack', {
'name': group['name']
})
for layer in group['layers']:
layer_attrs = {
'name': layer['name'],
'src': layer['src'],
'opacity': layer.get('opacity', '1.0')
}
if layer.get('visibility') and layer['visibility'] != 'visible':
layer_attrs['visibility'] = layer['visibility']
ET.SubElement(group_el, 'layer', layer_attrs)
else:
# Standalone layer (base layer)
layer = group['layers'][0]
layer_attrs = {
'name': layer['name'],
'src': layer['src'],
'opacity': layer.get('opacity', '1.0')
}
if layer.get('visibility') and layer['visibility'] != 'visible':
layer_attrs['visibility'] = layer['visibility']
ET.SubElement(root_stack, 'layer', layer_attrs)
return ET.tostring(image, encoding='utf-8', xml_declaration=True)
def create_ora_from_structure(
groups: list[dict],
width: int,
height: int,
layer_images: dict[str, Image.Image],
output_path: str
) -> None:
"""Create an ORA file from the structure and layer images."""
tmp = tempfile.mkdtemp()
try:
data_dir = os.path.join(tmp, 'data')
thumb_dir = os.path.join(tmp, 'Thumbnails')
os.makedirs(data_dir)
os.makedirs(thumb_dir)
# Save all layer images
for src_path, img in layer_images.items():
full_path = os.path.join(tmp, src_path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
img.save(full_path)
# Build and save stack.xml
stack_xml = build_stack_xml(width, height, groups)
with open(os.path.join(tmp, 'stack.xml'), 'wb') as f:
f.write(stack_xml)
# Create mimetype
with open(os.path.join(tmp, 'mimetype'), 'w') as f:
f.write('image/openraster')
# Create mergedimage.png (use first base layer)
merged = None
for group in groups:
if not group.get('is_group') and group['layers']:
layer_src = group['layers'][0]['src']
merged = layer_images.get(layer_src)
break
if merged is None:
# Create blank image if no base layer
merged = Image.new('RGBA', (width, height), (0, 0, 0, 0))
merged.save(os.path.join(tmp, 'mergedimage.png'))
# Create thumbnail
thumb = merged.copy()
thumb.thumbnail((256, 256))
thumb.save(os.path.join(thumb_dir, 'thumbnail.png'))
# Build ORA zip
with zipfile.ZipFile(output_path, 'w') as zf:
zf.write(os.path.join(tmp, 'mimetype'), 'mimetype',
compress_type=zipfile.ZIP_STORED)
for root, _, files in os.walk(tmp):
for file in files:
if file == 'mimetype':
continue
full = os.path.join(root, file)
rel = os.path.relpath(full, tmp)
zf.write(full, rel)
finally:
shutil.rmtree(tmp)
def print_ora_summary(groups: list[dict]) -> None:
"""Print an itemized summary of the ORA structure."""
print("\n" + "=" * 50)
print("ORA STRUCTURE SUMMARY")
print("=" * 50)
for i, group in enumerate(groups, 1):
if group.get('is_group'):
print(f"\n📁 Group {i}: {group['name']}")
for j, layer in enumerate(group['layers'], 1):
print(f" └─ Layer {j}: {layer['name']}")
else:
layer = group['layers'][0]
print(f"\n🖼️ Base Layer {i}: {layer['name']}")
print("\n" + "=" * 50)
def cmd_create(args: argparse.Namespace) -> int:
"""Handle 'create' subcommand."""
input_png = args.input_png
output_ora = args.output_ora
layer_name = args.layer_name
if output_ora is None:
output_ora = str(Path(input_png).with_suffix('.ora'))
# Load base image
base = Image.open(input_png).convert('RGBA')
width, height = base.size
# Build structure
layer_src = f'data/{layer_name}.png'
groups = [{
'name': layer_name,
'is_group': False,
'layers': [{
'name': layer_name,
'src': layer_src,
'opacity': '1.0',
'visibility': 'visible'
}]
}]
layer_images = {layer_src: base}
create_ora_from_structure(groups, width, height, layer_images, output_ora)
print(f"✓ Created ORA: {output_ora}")
print_ora_summary(groups)
return 0
def cmd_mask_element(args: argparse.Namespace) -> int:
"""Handle 'mask_element' subcommand."""
import io
input_path = args.input
mask_path = args.mask
entity_name = args.entity
layer_name = args.layer
output_path = args.output
# Handle PNG input: auto-create ORA first
is_png_input = input_path.lower().endswith('.png')
ora_path = input_path
if is_png_input:
ora_path = str(Path(input_path).with_suffix('.ora'))
if output_path is None:
output_path = ora_path
# Create ORA from PNG with 'base' layer
base = Image.open(input_path).convert('RGBA')
width, height = base.size
layer_src = 'data/base.png'
groups = [{
'name': 'base',
'is_group': False,
'layers': [{
'name': 'base',
'src': layer_src,
'opacity': '1.0',
'visibility': 'visible'
}]
}]
layer_images = {layer_src: base}
create_ora_from_structure(groups, width, height, layer_images, ora_path)
print(f"✓ Converted PNG to ORA: {ora_path}")
# Parse existing ORA
root = parse_stack_xml(ora_path)
width, height = get_image_size(root)
groups = get_groups_and_layers(root)
# Find source layer
layer_result = find_layer(groups, layer_name)
if layer_result is None:
print(f"Error: Layer '{layer_name}' not found in ORA file", file=sys.stderr)
print("Available layers:", file=sys.stderr)
for group in groups:
for layer in group['layers']:
print(f" - {layer['name']}", file=sys.stderr)
return 1
source_group, source_layer = layer_result
# Load source image
with zipfile.ZipFile(ora_path, 'r') as zf:
source_image_data = zf.read(source_layer['src'])
source_image = Image.open(io.BytesIO(source_image_data)).convert('RGBA')
# Load mask
mask_image = Image.open(mask_path)
if mask_image.mode not in ['L', 'RGBA', 'RGB']:
mask_image = mask_image.convert('RGBA')
# Apply mask
masked_image = apply_mask(source_image, mask_image)
# Find or create entity group
entity_group = find_entity_group(groups, entity_name)
if entity_group is None:
# Create new group before base layers
entity_group = {
'name': entity_name,
'is_group': True,
'layers': []
}
# Insert before first non-group (base layer)
insert_idx = 0
for i, g in enumerate(groups):
if not g.get('is_group'):
insert_idx = i
break
insert_idx = i + 1
groups.insert(insert_idx, entity_group)
# Generate new layer name (auto-increment)
next_index = get_next_layer_index(entity_group, entity_name)
new_layer_name = f"{entity_name}_{next_index}"
new_layer_src = f"data/{entity_name}/{new_layer_name}.png"
# Add new layer to entity group
entity_group['layers'].append({
'name': new_layer_name,
'src': new_layer_src,
'opacity': '1.0',
'visibility': 'visible'
})
# Extract all layer images from ORA
layer_images = {}
with zipfile.ZipFile(ora_path, 'r') as zf:
for group in groups:
for layer in group['layers']:
if layer['src'] not in layer_images:
try:
data = zf.read(layer['src'])
layer_images[layer['src']] = Image.open(io.BytesIO(data))
except KeyError:
pass # New layer, will be added later
# Add the new masked image
layer_images[new_layer_src] = masked_image
# Determine output path
if output_path is None:
output_path = ora_path
# Create new ORA
create_ora_from_structure(groups, width, height, layer_images, output_path)
print(f"✓ Applied mask to create: {entity_name}/{new_layer_name}")
print(f" Source layer: {layer_name}")
print(f" Mask file: {mask_path}")
print(f" Output: {output_path}")
print_ora_summary(groups)
return 0
def cmd_inspect(args: argparse.Namespace) -> int:
"""Handle 'inspect' subcommand."""
ora_path = args.ora_file
if not os.path.exists(ora_path):
print(f"Error: File not found: {ora_path}", file=sys.stderr)
return 1
# Parse existing ORA
try:
root = parse_stack_xml(ora_path)
except (zipfile.BadZipFile, KeyError) as e:
print(f"Error: Invalid ORA file: {ora_path}", file=sys.stderr)
return 1
groups = get_groups_and_layers(root)
print(f"\nFile: {ora_path}")
print_ora_summary(groups)
return 0
def cmd_extract_png(args: argparse.Namespace) -> int:
"""Handle 'extract_png' subcommand."""
ora_path = args.ora_file
layer_name = args.layer
output_path = args.output
if not os.path.exists(ora_path):
print(f"Error: File not found: {ora_path}", file=sys.stderr)
return 1
# Parse existing ORA
try:
root = parse_stack_xml(ora_path)
except (zipfile.BadZipFile, KeyError) as e:
print(f"Error: Invalid ORA file: {ora_path}", file=sys.stderr)
return 1
groups = get_groups_and_layers(root)
# Find the layer
layer_result = find_layer(groups, layer_name)
if layer_result is None:
print(f"Error: Layer '{layer_name}' not found in ORA file", file=sys.stderr)
print("Available layers:", file=sys.stderr)
for group in groups:
for layer in group['layers']:
print(f" - {layer['name']}", file=sys.stderr)
return 1
_, layer = layer_result
# Determine output path
if output_path is None:
output_path = f"{layer_name}.png"
# Extract and save the layer image
image = extract_layer_image(ora_path, layer['src'])
image.save(output_path)
print(f"✓ Extracted layer '{layer_name}' to: {output_path}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(
prog='ora_edit.py',
description='ORA (OpenRaster) editing utility'
)
subparsers = parser.add_subparsers(dest='command', required=True)
# Create subcommand
create_parser = subparsers.add_parser(
'create',
help='Create an ORA file from a PNG'
)
create_parser.add_argument(
'input_png',
help='Input PNG file'
)
create_parser.add_argument(
'output_ora',
nargs='?',
default=None,
help='Output ORA file (default: same name as input with .ora extension)'
)
create_parser.add_argument(
'--layer-name',
default='base',
help='Name for the root layer (default: base)'
)
create_parser.set_defaults(func=cmd_create)
# Mask element subcommand
mask_parser = subparsers.add_parser(
'mask_element',
help='Create a masked layer for an entity in an ORA file'
)
mask_parser.add_argument(
'input',
help='Input ORA or PNG file'
)
mask_parser.add_argument(
'--mask',
required=True,
help='Mask file to use as alpha channel'
)
mask_parser.add_argument(
'--entity',
required=True,
help='Entity name (group name)'
)
mask_parser.add_argument(
'--layer',
default='base',
help='Source layer to copy and mask (default: base)'
)
mask_parser.add_argument(
'--output',
default=None,
help='Output ORA file (default: same as input)'
)
mask_parser.set_defaults(func=cmd_mask_element)
# Inspect subcommand
inspect_parser = subparsers.add_parser(
'inspect',
help='Show the structure of an ORA file'
)
inspect_parser.add_argument(
'ora_file',
help='ORA file to inspect'
)
inspect_parser.set_defaults(func=cmd_inspect)
# Extract PNG subcommand
extract_parser = subparsers.add_parser(
'extract_png',
help='Extract a layer from an ORA file as a PNG'
)
extract_parser.add_argument(
'ora_file',
help='ORA file to extract from'
)
extract_parser.add_argument(
'--layer',
required=True,
help='Name of the layer to extract'
)
extract_parser.add_argument(
'--output',
default=None,
help='Output PNG file (default: <layer_name>.png)'
)
extract_parser.set_defaults(func=cmd_extract_png)
args = parser.parse_args()
return args.func(args)
if __name__ == '__main__':
sys.exit(main())

409
tools/ora_edit_README.md Normal file
View File

@@ -0,0 +1,409 @@
# ora_edit.py
A command-line utility for editing OpenRaster (ORA) files, supporting layer creation, masking, and entity management.
## Overview
`ora_edit.py` provides four main subcommands:
- `create`: Create a new ORA file from a PNG image
- `mask_element`: Add masked layers for specific entities within an ORA file
- `inspect`: Display the structure of an ORA file
- `extract_png`: Extract a layer from an ORA file as a PNG
## Usage
### Creating an ORA File
Create a new ORA file from a PNG:
```bash
python3 ora_edit.py create input.png [output.ora] [--layer-name NAME]
```
**Examples:**
```bash
# Create ORA with default layer name "base"
python3 ora_edit.py create scene.png scene.ora
# Create ORA with custom layer name
python3 ora_edit.py create scene.png scene.ora --layer-name "background"
# Auto-detect output name (creates scene.ora)
python3 ora_edit.py create scene.png
```
### Masking Elements
Add a masked layer for an entity using a black-and-white mask image:
```bash
python3 ora_edit.py mask_element <input> --mask <mask.png> --entity <name> [--layer LAYER] [--output OUTPUT]
```
**Examples:**
```bash
# Mask an element using default "base" layer
python3 ora_edit.py mask_element scene.ora --mask door_mask.png --entity "door"
# Specify which layer to copy and mask
python3 ora_edit.py mask_element scene.ora --mask window_mask.png --entity "window" --layer "background"
# Chain: Convert PNG to ORA, then add masked element
python3 ora_edit.py mask_element scene.png --mask tree_mask.png --entity "tree" --output scene_with_tree.ora
```
**How it works:**
- Creates an entity group (or uses existing one)
- Copies the specified source layer
- Applies the mask as the alpha channel
- Saves as `entity_N` (auto-incremented: door_0, door_1, etc.)
### Inspecting ORA Files
Display the layer structure and group hierarchy of an ORA file:
```bash
python3 ora_edit.py inspect <ora_file>
```
**Examples:**
```bash
# View structure of an ORA file
python3 ora_edit.py inspect scene.ora
# Check what's in a file before adding more elements
python3 ora_edit.py inspect character.ora
```
### Extracting Layers as PNG
Extract a specific layer from an ORA file and save it as a PNG:
```bash
python3 ora_edit.py extract_png <ora_file> --layer <layer_name> [--output <output.png>]
```
**Examples:**
```bash
# Extract a layer with default output name
python3 ora_edit.py extract_png scene.ora --layer door_0
# Extract with custom output name
python3 ora_edit.py extract_png scene.ora --layer background --output bg.png
# Extract all layers from an ORA (use with inspect)
for layer in $(python3 ora_edit.py inspect scene.ora | grep "Layer" | awk '{print $NF}'); do
python3 ora_edit.py extract_png scene.ora --layer "$layer" --output "${layer}.png"
done
```
**Output format:**
```
File: scene.ora
==================================================
ORA STRUCTURE SUMMARY
==================================================
📁 Group 1: door
└─ Layer 1: door_0
└─ Layer 2: door_1
📁 Group 2: window
└─ Layer 1: window_0
🖼️ Base Layer 3: background
==================================================
```
## Workflow Examples
### Example 1: Game Scene Composition
Building a scene with multiple masked elements:
```bash
# Start with background
python3 ora_edit.py create background.png scene.ora --layer-name "room"
# Add a door masked from the room layer
python3 ora_edit.py mask_element scene.ora \
--mask door_alpha.png \
--entity "door" \
--layer "room"
# Add a window
python3 ora_edit.py mask_element scene.ora \
--mask window_alpha.png \
--entity "window" \
--layer "room"
# Add another door variant (auto-increments to door_1)
python3 ora_edit.py mask_element scene.ora \
--mask door_open_alpha.png \
--entity "door" \
--layer "room"
# Check the final structure
python3 ora_edit.py inspect scene.ora
```
**Resulting structure:**
```
📁 Group: door
└─ door_0
└─ door_1
📁 Group: window
└─ window_0
🖼️ Base Layer: room
```
### Example 2: Character Animation
Creating animation frames with masked body parts:
```bash
# Create base character
python3 ora_edit.py create character_base.png character.ora --layer-name "body"
# Add masked arm that can be animated
python3 ora_edit.py mask_element character.ora \
--mask arm_mask.png \
--entity "arm" \
--layer "body"
# Add masked head
python3 ora_edit.py mask_element character.ora \
--mask head_mask.png \
--entity "head" \
--layer "body"
```
### Example 3: AI-Generated Assets
Working with AI-generated masks:
```bash
# Generate mask using ComfyUI or other tool
python3 extract_mask.py "the wooden chest" scene.png chest_mask.png
# Add to ORA
python3 ora_edit.py mask_element scene.ora \
--mask chest_mask.png \
--entity "chest" \
--layer "background"
```
### Example 4: Iterative Design
Refining elements with multiple mask attempts:
```bash
# First iteration
python3 ora_edit.py mask_element scene.ora \
--mask door_v1.png \
--entity "door" \
--layer "background"
# Second iteration (creates door_1)
python3 ora_edit.py mask_element scene.ora \
--mask door_v2.png \
--entity "door" \
--layer "background"
# Third iteration (creates door_2)
python3 ora_edit.py mask_element scene.ora \
--mask door_v3.png \
--entity "door" \
--layer "background"
```
The ORA now contains all three versions to compare in GIMP/Krita.
### Example 5: Layer-Based Masking
Using different source layers for different entities:
```bash
# Create ORA with multiple base layers
python3 ora_edit.py create day_scene.png scene.ora --layer-name "day"
python3 ora_edit.py create night_scene.png scene_night.ora --layer-name "night"
# Extract and use specific layers
# (Assume we've combined these into one ORA manually or via script)
# Mask using day layer
python3 ora_edit.py mask_element scene.ora \
--mask sun_mask.png \
--entity "sun" \
--layer "day"
# Mask using night layer
python3 ora_edit.py mask_element scene.ora \
--mask moon_mask.png \
--entity "moon" \
--layer "night"
```
### Example 6: Exporting Layers for External Use
Extracting layers for use in other tools or workflows:
```bash
# Check what layers exist
python3 ora_edit.py inspect character.ora
# Extract specific animation frames
python3 ora_edit.py extract_png character.ora --layer arm_0 --output arm_frame1.png
python3 ora_edit.py extract_png character.ora --layer arm_1 --output arm_frame2.png
# Extract background for compositing
python3 ora_edit.py extract_png scene.ora --layer background --output bg_composite.png
```
## Command Reference
### `create`
Create an ORA file from a PNG image.
```
positional arguments:
input_png Input PNG file
output_ora Output ORA file (default: same name with .ora extension)
options:
-h, --help Show help message
--layer-name NAME Name for the root layer (default: base)
```
### `mask_element`
Create a masked layer for an entity.
```
positional arguments:
input Input ORA or PNG file
options:
-h, --help Show help message
--mask MASK Mask file to use as alpha channel (required)
--entity ENTITY Entity name/group name (required)
--layer LAYER Source layer to copy and mask (default: base)
--output OUTPUT Output ORA file (default: same as input)
```
**Important behaviors:**
- If input is PNG: Automatically converts to ORA first, using "base" as the layer name
- Entity groups are created above base layers in the stack
- Layer names auto-increment within groups (entity_0, entity_1, ...)
- If the specified `--layer` doesn't exist, the command errors and lists available layers
### `inspect`
Display the structure of an ORA file without modifying it.
```
positional arguments:
ora_file ORA file to inspect
options:
-h, --help Show help message
```
**Output includes:**
- File path
- Entity groups with their layer counts
- Base layers
- Total layer/group statistics
### `extract_png`
Extract a layer from an ORA file as a PNG image.
```
positional arguments:
ora_file ORA file to extract from
options:
-h, --help Show help message
--layer LAYER Name of the layer to extract (required)
--output OUTPUT Output PNG file (default: <layer_name>.png)
```
**Use cases:**
- Export layers for use in other graphics applications
- Extract masked elements for game development
- Create individual assets from composed ORA files
- Backup or archive specific layers
**Important behaviors:**
- If the specified `--layer` doesn't exist, the command errors and lists available layers
- Output defaults to `<layer_name>.png` if not specified
- Preserves transparency (RGBA) from the original layer
## Tips
### Mask Requirements
- Masks can be grayscale (L mode) or RGB/RGBA
- White = fully opaque, Black = fully transparent
- Grayscale values create partial transparency
### Layer Organization
- Entity groups appear first in the layer stack
- Base layers appear at the end
- This organization makes it easy to toggle entities on/off in GIMP/Krita
### Workflow Integration
- Combine with `extract_mask.py` for AI-powered masking
- Use `mask_to_polygon.py` to convert masks to Godot collision polygons
- Chain multiple `mask_element` calls in shell scripts for batch processing
## Error Handling
The tool provides clear error messages:
```bash
# Missing layer
$ python3 ora_edit.py mask_element scene.ora --mask x.png --entity door --layer "missing"
Error: Layer 'missing' not found in ORA file
Available layers:
- base
- door_0
- door_1
```
## Dependencies
- Python 3.8+
- Pillow (PIL)
Install dependencies:
```bash
pip install Pillow
```
## ORA Format
OpenRaster (.ora) is an open standard for layered raster graphics. This tool creates ORA 0.0.3 compatible files that can be opened in:
- GIMP (with ora plugin)
- Krita
- MyPaint
- Other ORA-supporting applications
The generated structure:
```
archive.ora/
├── mimetype # "image/openraster"
├── stack.xml # Layer hierarchy
├── mergedimage.png # Flattened preview
├── Thumbnails/
│ └── thumbnail.png # 256x256 preview
└── data/
├── base.png # Layer images
└── entity/
└── entity_0.png
```