progress
This commit is contained in:
BIN
tools/.output.ora-autosave.kra
Normal file
BIN
tools/.output.ora-autosave.kra
Normal file
Binary file not shown.
@@ -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
608
tools/ora_edit.py
Executable 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
409
tools/ora_edit_README.md
Normal 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
|
||||
```
|
||||
Reference in New Issue
Block a user