Files
WolfensteinVB/graphics/prepare_marine_sprites.py
2026-02-19 23:28:57 +01:00

446 lines
17 KiB
Python

"""
prepare_marine_sprites.py
Convert Marine (Doom player) frames to VB tile format for player 2 rendering.
Source: make_into_4_colors/converted/frames/Marine/ (51 PNGs)
Ideal source: magenta (255,0,255) background for transparency, like the zombie
pipeline (make_into_4_colors_2/convert_to_vb.py). Otherwise background is
detected from corners or most common color; only that background maps to
palette index 0 (transparent), sprite pixels to 1-3.
These frames were extracted sequentially from a Doom player sprite sheet.
Likely layout (5 unique directions per pose, dirs 6-8 mirror 4-2):
0-4: Walk A, directions 1-5 (front, front-left, left, back-left, back)
5-9: Walk B, directions 1-5
10-14: Walk C, directions 1-5
15-19: Walk D, directions 1-5
20-24: Attack E, directions 1-5
25-29: Attack F, directions 1-5
30-34: Pain G, directions 1-5
35-50: Death H-X (16 frames, direction-independent)
Pipeline:
1. Convert each frame PNG to VB 4-color palette
2. Pad to 64x64 (8x8 tiles = 64 chars per frame)
3. Run grit.exe on each frame
4. Generate marine_sprites.h with frame pointer table
Output: src/vbdoom/assets/images/sprites/marine/
"""
import os
import subprocess
import shutil
import re
from PIL import Image
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SOURCE_DIR = os.path.join(SCRIPT_DIR, "make_into_4_colors", "converted", "frames", "Marine")
PROJECT_ROOT = SCRIPT_DIR
GRIT_EXE = os.path.join(PROJECT_ROOT, 'grit_conversions', 'grit.exe')
GRIT_DIR = os.path.join(PROJECT_ROOT, 'grit_conversions')
SPRITES_BASE = os.path.join(PROJECT_ROOT, 'src', 'vbdoom', 'assets', 'images', 'sprites')
OUTPUT_DIR = os.path.join(SPRITES_BASE, 'marine')
GRIT_FLAGS = ['-fh!', '-ftc', '-gB2', '-p!', '-m!']
# VB Palette
VB_PALETTE = [
(0, 0, 0), # 0: Black (transparent)
(85, 0, 0), # 1: Dark red
(164, 0, 0), # 2: Medium red
(239, 0, 0), # 3: Bright red
]
TILE_SIZE = 8
TARGET_SIZE = 64 # 8x8 tiles
NUM_FRAMES = 51
def luminance(r, g, b):
return int(0.299 * r + 0.587 * g + 0.114 * b)
def is_magenta(r, g, b):
return (r > 220 and g < 30 and b > 220)
def map_to_vb_color(gray):
if gray < 85:
return 1
elif gray < 170:
return 2
else:
return 3
# Sentinel for "magenta background" (same as zombie pipeline: index 0 = transparent).
MAGENTA_BG = (255, 0, 255)
def get_background_color(img, alpha_data):
"""Determine background color: prefer magenta (like zombie Option C), else most common color, else top-left."""
w, h = img.size
corners = [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)]
for (x, y) in corners:
if x < w and y < h:
a = alpha_data.getpixel((x, y))
if a < 128:
return None # already transparent
r, g, b = img.getpixel((x, y))
if is_magenta(r, g, b):
return MAGENTA_BG
# Most common color (exact) as fallback; if it dominates, use as background
color_count = {}
for y in range(h):
for x in range(w):
if alpha_data.getpixel((x, y)) >= 128:
c = img.getpixel((x, y))
color_count[c] = color_count.get(c, 0) + 1
if color_count:
total = sum(color_count.values())
most_common = max(color_count.items(), key=lambda t: t[1])
if most_common[1] > total * 0.20:
return most_common[0]
r, g, b = img.getpixel((0, 0))
return (r, g, b)
def is_background(r, g, b, bg, tolerance=12):
"""True if (r,g,b) is background (transparent). Magenta always counts; else match with small tolerance."""
if bg is None:
return False
if bg == MAGENTA_BG:
return is_magenta(r, g, b)
return (abs(r - bg[0]) <= tolerance and
abs(g - bg[1]) <= tolerance and
abs(b - bg[2]) <= tolerance)
def enhance_contrast(img):
"""Adaptive contrast enhancement for sprite pixels."""
pixels = []
for y in range(img.height):
for x in range(img.width):
r, g, b = img.getpixel((x, y))[:3]
if not is_magenta(r, g, b):
gray = luminance(r, g, b)
if gray > 5:
pixels.append(gray)
if not pixels:
return img
pixels.sort()
lo = pixels[int(len(pixels) * 0.02)]
hi = pixels[int(len(pixels) * 0.98)]
if hi <= lo:
return img
out = img.copy()
for y in range(img.height):
for x in range(img.width):
r, g, b = img.getpixel((x, y))[:3]
if not is_magenta(r, g, b):
gray = luminance(r, g, b)
if gray <= 5:
stretched = gray
else:
stretched = int(255.0 * (gray - lo) / (hi - lo))
stretched = max(0, min(255, stretched))
out.putpixel((x, y), (stretched, stretched, stretched))
return out
def convert_frame_to_vb(input_path):
"""Convert a single frame PNG to 64x64 indexed image (0=transparent, 1-3=reds).
Returns the indexed PIL Image for tile generation. Background becomes index 0."""
raw = Image.open(input_path)
img_rgba = raw.convert('RGBA')
img = img_rgba.convert('RGB')
alpha_data = img_rgba.split()[3]
enhanced = enhance_contrast(img)
bg = get_background_color(img, alpha_data)
w, h = img.size
# Find bounding box of non-background content
min_x, min_y, max_x, max_y = w, h, 0, 0
has_content = False
for y in range(h):
for x in range(w):
a = alpha_data.getpixel((x, y))
if a < 128:
continue
r, g, b = img.getpixel((x, y))
if is_magenta(r, g, b) or (r < 5 and g < 5 and b < 5) or is_background(r, g, b, bg):
continue
min_x = min(min_x, x)
min_y = min(min_y, y)
max_x = max(max_x, x)
max_y = max(max_y, y)
has_content = True
if not has_content:
pal_img = Image.new('P', (TARGET_SIZE, TARGET_SIZE), 0)
pal_img.putpalette([c for i in range(4) for c in VB_PALETTE[i]])
return pal_img
cw = max_x - min_x + 1
ch = max_y - min_y + 1
scale = min(TARGET_SIZE / cw, TARGET_SIZE / ch)
if scale > 1.0:
scale = 1.0
nw = max(1, int(cw * scale))
nh = max(1, int(ch * scale))
cropped = img.crop((min_x, min_y, max_x + 1, max_y + 1))
enhanced_crop = enhanced.crop((min_x, min_y, max_x + 1, max_y + 1))
alpha_crop = alpha_data.crop((min_x, min_y, max_x + 1, max_y + 1))
if scale < 1.0:
cropped = cropped.resize((nw, nh), Image.LANCZOS)
enhanced_crop = enhanced_crop.resize((nw, nh), Image.LANCZOS)
alpha_crop = alpha_crop.resize((nw, nh), Image.LANCZOS)
out = Image.new('RGB', (TARGET_SIZE, TARGET_SIZE), VB_PALETTE[0])
off_x = (TARGET_SIZE - nw) // 2
off_y = TARGET_SIZE - nh # bottom-aligned
for y in range(nh):
for x in range(nw):
a = alpha_crop.getpixel((x, y))
if a < 128:
continue
r, g, b = cropped.getpixel((x, y))
if is_magenta(r, g, b) or is_background(r, g, b, bg):
continue
er = enhanced_crop.getpixel((x, y))[0]
idx = map_to_vb_color(er)
out.putpixel((off_x + x, off_y + y), VB_PALETTE[idx])
# Build indexed image (0=black/transparent, 1-3=reds) for our own tile generator.
pal_img = Image.new('P', (TARGET_SIZE, TARGET_SIZE), 0)
pal_list = [c for i in range(4) for c in VB_PALETTE[i]]
pal_img.putpalette(pal_list)
for py in range(TARGET_SIZE):
for px in range(TARGET_SIZE):
r, g, b = out.getpixel((px, py))
idx = 0
for i in range(4):
if (r, g, b) == VB_PALETTE[i]:
idx = i
break
pal_img.putpixel((px, py), idx)
return pal_img
def indexed_image_to_vb_tiles(pal_img):
"""Convert 64x64 indexed image (0-3) to VB 2bpp tile data: 64 tiles, 16 bytes each = 256 u32.
Same format as prepare_particles.py / prepare_keycard_sprites.py: each row = one u16 (LE),
pixel 0 at bits[1:0] (LSB), then 4 u32 per tile = 2 rows each (row0_lo, row0_hi, row1_lo, row1_hi)."""
TILE_W, TILE_H = 8, 8
tiles = []
for ty in range(8):
for tx in range(8):
row_bytes = []
for row in range(8):
y = ty * 8 + row
x0 = tx * 8
p = [min(3, pal_img.getpixel((x0 + i, y))) for i in range(8)]
u16_val = sum((p[x] << (x * 2)) for x in range(8))
row_bytes.append(u16_val & 0xFF)
row_bytes.append((u16_val >> 8) & 0xFF)
# 4 u32 per tile: bytes 0-3, 4-7, 8-11, 12-15 (same as bytes_to_u32)
for i in range(0, 16, 4):
w = (row_bytes[i] | (row_bytes[i + 1] << 8) |
(row_bytes[i + 2] << 16) | (row_bytes[i + 3] << 24))
tiles.append(w)
return tiles
def write_marine_c_file(tile_words, output_path, array_name):
"""Write one Marine_NNN.c file with the tile array (256 u32)."""
lines = [
"",
"//{{BLOCK(" + array_name.replace("Tiles", "") + ")",
"//",
"// " + array_name + ", 64x64@2, VB 2bpp (index 0 = transparent)",
"// Generated by prepare_marine_sprites.py",
"//",
"//}}BLOCK(" + array_name.replace("Tiles", "") + ")",
"",
"const unsigned int " + array_name + "[256] __attribute__((aligned(4)))=",
"{",
]
for i in range(0, 256, 8):
row = "\t" + ",".join("0x{:08X}".format(tile_words[i + j]) for j in range(8)) + ","
lines.append(row)
lines[-1] = lines[-1].rstrip(",")
lines.append("};")
lines.append("")
with open(output_path, 'w') as f:
f.write("\n".join(lines))
def run_grit(png_path):
"""Run grit on a single PNG file."""
cmd = [GRIT_EXE, png_path] + GRIT_FLAGS
result = subprocess.run(cmd, cwd=GRIT_DIR, capture_output=True, text=True)
if result.returncode != 0:
print(f" ERROR running grit: {result.stderr}")
return None
basename = os.path.splitext(os.path.basename(png_path))[0]
c_file = os.path.join(GRIT_DIR, f'{basename}.c')
if not os.path.exists(c_file):
print(f" ERROR: Expected output not found: {c_file}")
return None
return c_file
def get_tiles_array_name(c_file_path):
"""Extract the Tiles array name and size from a grit .c file."""
with open(c_file_path, 'r') as f:
content = f.read()
match = re.search(r'const unsigned int (\w+Tiles)\[(\d+)\]', content)
if match:
return match.group(1), int(match.group(2))
return None, None
def main():
print("=== Marine Sprite Converter ===\n")
if not os.path.exists(SOURCE_DIR):
print(f"ERROR: Source dir not found: {SOURCE_DIR}")
return
frame_files = [f"Marine_{i:03d}.png" for i in range(NUM_FRAMES)]
missing = [f for f in frame_files if not os.path.exists(os.path.join(SOURCE_DIR, f))]
if missing:
print(f"WARNING: Missing {len(missing)} frames: {missing[:5]}...")
frame_files = [f for f in frame_files if os.path.exists(os.path.join(SOURCE_DIR, f))]
print(f"Found {len(frame_files)} frames to process")
os.makedirs(OUTPUT_DIR, exist_ok=True)
TILES_PER_FRAME = 256 # 64 tiles * 4 u32 per tile
tile_arrays = []
for i, frame_file in enumerate(frame_files):
src_path = os.path.join(SOURCE_DIR, frame_file)
pal_img = convert_frame_to_vb(src_path)
if pal_img is None:
continue
tile_words = indexed_image_to_vb_tiles(pal_img)
array_name = f"marine_{i:03d}Tiles"
dest_c = os.path.join(OUTPUT_DIR, f"Marine_{i:03d}.c")
write_marine_c_file(tile_words, dest_c, array_name)
tile_arrays.append((array_name, TILES_PER_FRAME, i))
if (i + 1) % 10 == 0 or i == len(frame_files) - 1:
print(f" Converted {i + 1}/{len(frame_files)} frames...")
# Copy source PNGs for reference
png_dir = os.path.join(OUTPUT_DIR, 'png')
os.makedirs(png_dir, exist_ok=True)
for f in frame_files:
shutil.copy2(os.path.join(SOURCE_DIR, f), os.path.join(png_dir, f))
generate_header(tile_arrays, TILES_PER_FRAME)
print(f"\nDone! {len(tile_arrays)} frames converted.")
def generate_header(tile_arrays, tiles_per_frame):
"""Generate marine_sprites.h with frame pointer table and direction LUTs."""
header_path = os.path.join(OUTPUT_DIR, "marine_sprites.h")
guard = "__MARINE_SPRITES_H__"
bytes_per_frame = tiles_per_frame * 4 if tiles_per_frame else 0
lines = []
lines.append(f"#ifndef {guard}")
lines.append(f"#define {guard}")
lines.append(f"")
lines.append(f"/*")
lines.append(f" * Marine (player 2) sprite frames")
lines.append(f" * Auto-generated by prepare_marine_sprites.py")
lines.append(f" *")
lines.append(f" * Total frames: {len(tile_arrays)}")
lines.append(f" * Frame size: 64x64 px (8x8 tiles)")
lines.append(f" * Tiles per frame: {tiles_per_frame} u32 words = {bytes_per_frame} bytes")
lines.append(f" *")
lines.append(f" * Frame layout:")
lines.append(f" * Walk: front 000,001,002,005 | front-left 006-008,011 | left 012-015 | back-left 018-020,023 | back 024-026,029")
lines.append(f" * Shoot: front 003,004 | front-left 009,010 | left 016,017 | back-left 021,022 | back 027,028 (mirror for right)")
lines.append(f" * Pain: 030 fl, 031 f, 032 l, 033 b, 034 bl | Death: 035-041 | Gibbed: 042-049")
lines.append(f" */")
lines.append(f"")
for array_name, array_size, idx in tile_arrays:
lines.append(f"extern const unsigned int {array_name}[{array_size}];")
lines.append(f"")
lines.append(f"#define MARINE_FRAME_COUNT {len(tile_arrays)}")
lines.append(f"#define MARINE_FRAME_BYTES {bytes_per_frame}")
lines.append(f"#define MARINE_WALK_ANIM_FRAMES 4")
lines.append(f"#define MARINE_ATTACK_ANIM_FRAMES 2")
lines.append(f"#define MARINE_DEATH_FRAMES 7")
lines.append(f"#define MARINE_GIB_FRAMES 8")
lines.append(f"")
lines.append(f"static const unsigned int* const MARINE_FRAMES[{len(tile_arrays)}] = {{")
for i, (array_name, array_size, idx) in enumerate(tile_arrays):
comma = "," if i < len(tile_arrays) - 1 else ""
lines.append(f" {array_name}{comma}")
lines.append(f"}};")
lines.append(f"")
# Walk/attack/pain/death/gib LUTs (frame indices from source Marine_000.png .. Marine_050.png)
lines.append(f"/* Walk frames: [direction][pose] -> frame index */")
lines.append(f"static const u8 MARINE_WALK_FRAMES[8][4] = {{")
lines.append(f" {{ 0, 1, 2, 5}}, /* dir 0: front */")
lines.append(f" {{ 6, 7, 8, 11}}, /* dir 1: front-left */")
lines.append(f" {{ 12, 13, 14, 15}}, /* dir 2: left */")
lines.append(f" {{ 18, 19, 20, 23}}, /* dir 3: back-left */")
lines.append(f" {{ 24, 25, 26, 29}}, /* dir 4: back */")
lines.append(f" {{ 18, 19, 20, 23}}, /* dir 5: back-right (mirror 3) */")
lines.append(f" {{ 12, 13, 14, 15}}, /* dir 6: right (mirror 2) */")
lines.append(f" {{ 6, 7, 8, 11}}, /* dir 7: front-right (mirror 1) */")
lines.append(f"}};")
lines.append(f"")
lines.append(f"/* Attack frames: [direction][pose] -> frame index */")
lines.append(f"static const u8 MARINE_ATTACK_FRAMES[8][2] = {{")
lines.append(f" {{ 3, 4}}, /* dir 0: front */")
lines.append(f" {{ 9, 10}}, /* dir 1: front-left */")
lines.append(f" {{16, 17}}, /* dir 2: left */")
lines.append(f" {{21, 22}}, /* dir 3: back-left */")
lines.append(f" {{27, 28}}, /* dir 4: back */")
lines.append(f" {{21, 22}}, /* dir 5: back-right (mirror 3) */")
lines.append(f" {{16, 17}}, /* dir 6: right (mirror 2) */")
lines.append(f" {{ 9, 10}}, /* dir 7: front-right (mirror 1) */")
lines.append(f"}};")
lines.append(f"")
lines.append(f"/* Pain frames: [direction] -> frame index (030 fl, 031 f, 032 l, 033 b, 034 bl) */")
lines.append(f"static const u8 MARINE_PAIN_FRAMES[8] = {{31, 30, 32, 34, 33, 34, 32, 30}};")
lines.append(f"")
lines.append(f"/* Death frames: 035-041 (7 frames) */")
lines.append(f"static const u8 MARINE_DEATH_FRAMES_LUT[7] = {{35, 36, 37, 38, 39, 40, 41}};")
lines.append(f"")
lines.append(f"/* Gibbed frames: 042-049 (8 frames) */")
lines.append(f"static const u8 MARINE_GIB_FRAMES_LUT[8] = {{42, 43, 44, 45, 46, 47, 48, 49}};")
lines.append(f"")
lines.append(f"#endif /* {guard} */")
lines.append(f"")
with open(header_path, 'w') as f:
f.write('\n'.join(lines))
print(f" Header generated: marine_sprites.h")
if __name__ == '__main__':
main()