Skip to content

Instantly share code, notes, and snippets.

@celsowm
Created September 28, 2025 03:51
Show Gist options
  • Select an option

  • Save celsowm/1635011255a482343bbda6848d932bec to your computer and use it in GitHub Desktop.

Select an option

Save celsowm/1635011255a482343bbda6848d932bec to your computer and use it in GitHub Desktop.
saturn_image_tool.py for Jo Engine
#!/usr/bin/env python3
# saturn_image_tool.py
# GUI para converter TGA/PNG/JPG para TGA 8bpp (indexed), sem RLE, origem TOP-LEFT,
# com opções de redimensionamento.
#
# Requisitos: pip install pillow
import io
import os
import sys
import struct
from pathlib import Path
from typing import List, Tuple, Optional
try:
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
except Exception as e:
print("Erro ao importar tkinter (GUI). Instale o pacote do sistema para Tk.")
raise
try:
from PIL import Image, ImageTk
except ImportError as e:
print("Pillow não encontrado. Instale com: pip install pillow")
raise
# ------------------------
# Utilidades TGA
# ------------------------
IMG_TYPES = {
0: "No image data",
1: "Uncompressed, color-mapped",
2: "Uncompressed, true-color",
3: "Uncompressed, grayscale",
9: "RLE, color-mapped",
10:"RLE, true-color",
11:"RLE, grayscale",
}
def read_tga_header_bytes(hdr: bytes) -> dict:
if len(hdr) != 18:
raise ValueError("Header TGA inválido (esperado 18 bytes).")
(id_len, cmap_type, img_type,
cmap_first, cmap_len, cmap_entry_bits,
x_origin, y_origin, width, height,
pixel_depth, img_desc) = struct.unpack("<BBBHHBHHHHBB", hdr)
return {
"id_len": id_len,
"cmap_type": cmap_type,
"img_type": img_type,
"cmap_first": cmap_first,
"cmap_len": cmap_len,
"cmap_entry_bits": cmap_entry_bits,
"x_origin": x_origin,
"y_origin": y_origin,
"width": width,
"height": height,
"pixel_depth": pixel_depth,
"img_desc": img_desc,
"origin_top": bool(img_desc & 0x20),
"origin_left": not bool(img_desc & 0x10), # 0 = left-to-right
"alpha_bits": img_desc & 0x0F,
}
def read_tga_header(path: Path) -> dict:
with open(path, "rb") as f:
hdr = f.read(18)
return read_tga_header_bytes(hdr)
def force_tga_origin_top_left(path: Path) -> None:
"""
Força o bit 5 (0x20) do Image Descriptor (byte 18 = offset 17) para TOP-LEFT.
Mantém os demais bits (incluindo alpha bits).
"""
with open(path, "r+b") as f:
header = bytearray(f.read(18))
if len(header) != 18:
raise ValueError("Arquivo muito curto para TGA.")
img_desc = header[17]
img_desc |= 0x20 # seta TOP (bit 5)
img_desc &= 0xEF # limpa bit 4 (garante left-to-right) por segurança
header[17] = img_desc
f.seek(0)
f.write(header)
def inspect_tga_string(path: Path) -> str:
H = read_tga_header(path)
lines = []
lines.append(f"Arquivo: {path.name}")
lines.append(f"Dimensões: {H['width']}x{H['height']}")
lines.append(f"BPP (pixel_depth): {H['pixel_depth']}")
lines.append(f"Tipo de imagem: {H['img_type']} ({IMG_TYPES.get(H['img_type'], 'desconhecido')})")
lines.append(f"ColorMap: type={H['cmap_type']} len={H['cmap_len']} first={H['cmap_first']} entry_bits={H['cmap_entry_bits']}")
lines.append(f"Origem: {'TOP' if H['origin_top'] else 'BOTTOM'}-{'LEFT' if H['origin_left'] else 'RIGHT'}")
lines.append(f"Alpha bits (desc): {H['alpha_bits']}")
problems = []
if H["pixel_depth"] != 8:
problems.append("Não é 8bpp (indexed).")
if H["cmap_type"] != 1 or H["cmap_len"] == 0:
problems.append("Sem paleta válida (cmap).")
if H["img_type"] not in (1, 9):
problems.append("Tipo não é color-mapped (1) nem RLE color-mapped (9).")
if H["img_type"] == 9:
problems.append("RLE habilitado — alguns loaders 8bpp não suportam.")
if not H["origin_top"]:
problems.append("Origem BOTTOM — experimente exportar com origem TOP (ou inverter vertical).")
if H["cmap_entry_bits"] not in (24, 32):
problems.append("Tamanho de entrada da paleta incomum (≠24/32 bits).")
if H["width"] & (H["width"]-1) or H["height"] & (H["height"]-1):
problems.append("Dimensões não potência-de-2 (pode causar problemas em planos/backgrounds).")
if problems:
lines.append("\n⚠️ Possíveis problemas para o Saturn/jo:")
for p in problems:
lines.append(" - " + p)
else:
lines.append("\n✓ Header parece compatível para VDP2 8bpp com paleta.")
return "\n".join(lines)
# ------------------------
# Conversão
# ------------------------
def load_image_any(path: Path) -> Image.Image:
im = Image.open(path)
return im
def apply_transformations(im: Image.Image, rotate_deg: int, flip_vertical: bool) -> Image.Image:
out = im
if flip_vertical:
out = out.transpose(Image.FLIP_TOP_BOTTOM)
if rotate_deg == 90:
out = out.transpose(Image.ROTATE_270) # Pillow sentido anti-horário
elif rotate_deg == 180:
out = out.transpose(Image.ROTATE_180)
elif rotate_deg == 270:
out = out.transpose(Image.ROTATE_90)
return out
def next_pow2(n: int) -> int:
if n <= 1:
return 1
p = 1
while p < n:
p <<= 1
return p
def apply_resize(im: Image.Image, enable: bool, target_w: int, target_h: int,
keep_aspect: bool, round_pow2: bool) -> Image.Image:
if not enable:
return im
w, h = im.size
target_w = max(1, int(target_w))
target_h = max(1, int(target_h))
if keep_aspect:
# encaixa dentro de (target_w, target_h)
scale = min(target_w / w, target_h / h)
new_w = max(1, int(round(w * scale)))
new_h = max(1, int(round(h * scale)))
else:
new_w, new_h = target_w, target_h
if round_pow2:
new_w = next_pow2(new_w)
new_h = next_pow2(new_h)
if (new_w, new_h) != (w, h):
# NEAREST preserva pixels para assets 2D
im = im.resize((new_w, new_h), Image.NEAREST)
return im
def convert_to_tga8(im: Image.Image, colors: int) -> Image.Image:
colors = max(1, min(256, int(colors)))
return im.convert("P", palette=Image.ADAPTIVE, colors=colors)
def save_tga_uncompressed_top_left(im: Image.Image, dst_path: Path) -> None:
# Salva TGA sem compressão
im.save(dst_path, format="TGA", compress=False)
# Garante TOP-LEFT no header
force_tga_origin_top_left(dst_path)
def convert_file(path: Path, out_dir: Path, colors: int, rotate_deg: int, flip_vertical: bool, overwrite: bool,
resize_enable: bool, resize_w: int, resize_h: int, keep_aspect: bool, round_pow2: bool) -> Path:
out_dir.mkdir(parents=True, exist_ok=True)
dst_name = f"{path.stem}_8.tga"
dst = out_dir / dst_name
if dst.exists() and not overwrite:
raise FileExistsError(f"Arquivo de saída já existe: {dst} (use sobrescrever)")
im = load_image_any(path)
# 1) transformações geométricas simples (flip/rotate)
im = apply_transformations(im, rotate_deg=rotate_deg, flip_vertical=flip_vertical)
# 2) redimensionamento (opcional)
im = apply_resize(im, enable=resize_enable, target_w=resize_w, target_h=resize_h,
keep_aspect=keep_aspect, round_pow2=round_pow2)
# 3) paletização
im = convert_to_tga8(im, colors=colors)
# 4) salvar como TGA plano e forçar TOP-LEFT
save_tga_uncompressed_top_left(im, dst)
return dst
# ------------------------
# GUI
# ------------------------
class SaturnToolGUI(tk.Tk):
def __init__(self):
super().__init__()
self.title("Saturn Image Tool — TGA 8bpp TOP-LEFT")
self.geometry("1000x720")
self.minsize(940, 600)
self.files: List[Path] = []
self.preview_image_tk: Optional[ImageTk.PhotoImage] = None
self.preview_canvas_img = None
# Widgets principais
self.create_widgets()
def create_widgets(self):
# Top toolbar
toolbar = ttk.Frame(self)
toolbar.pack(side=tk.TOP, fill=tk.X, padx=8, pady=6)
btn_add = ttk.Button(toolbar, text="Adicionar arquivos…", command=self.add_files)
btn_add.pack(side=tk.LEFT)
btn_remove = ttk.Button(toolbar, text="Remover selecionados", command=self.remove_selected)
btn_remove.pack(side=tk.LEFT, padx=6)
btn_clear = ttk.Button(toolbar, text="Limpar lista", command=self.clear_list)
btn_clear.pack(side=tk.LEFT)
# Output dir
self.output_dir_var = tk.StringVar(value=str(Path.cwd()))
out_frame = ttk.Frame(toolbar)
out_frame.pack(side=tk.LEFT, padx=16)
ttk.Label(out_frame, text="Pasta de saída:").pack(side=tk.LEFT)
self.output_dir_entry = ttk.Entry(out_frame, width=48, textvariable=self.output_dir_var)
self.output_dir_entry.pack(side=tk.LEFT, padx=6)
ttk.Button(out_frame, text="Escolher…", command=self.choose_output_dir).pack(side=tk.LEFT)
# Options
opts = ttk.Labelframe(self, text="Opções de conversão")
opts.pack(side=tk.TOP, fill=tk.X, padx=8, pady=6)
self.colors_var = tk.IntVar(value=256)
ttk.Label(opts, text="Cores (1..256):").pack(side=tk.LEFT, padx=(8,4))
self.colors_spin = ttk.Spinbox(opts, from_=1, to=256, textvariable=self.colors_var, width=6)
self.colors_spin.pack(side=tk.LEFT, padx=4)
self.rotate_var = tk.StringVar(value="0")
ttk.Label(opts, text="Rotação:").pack(side=tk.LEFT, padx=(12,4))
self.rotate_combo = ttk.Combobox(opts, values=["0","90","180","270"], width=5, textvariable=self.rotate_var, state="readonly")
self.rotate_combo.pack(side=tk.LEFT, padx=4)
self.flip_var = tk.BooleanVar(value=True)
self.flip_check = ttk.Checkbutton(opts, text="Flip vertical (forçar TOP-LEFT)", variable=self.flip_var)
self.flip_check.pack(side=tk.LEFT, padx=(12,4))
self.overwrite_var = tk.BooleanVar(value=False)
self.overwrite_check = ttk.Checkbutton(opts, text="Sobrescrever", variable=self.overwrite_var)
self.overwrite_check.pack(side=tk.LEFT, padx=(12,4))
ttk.Button(opts, text="Converter selecionados", command=self.convert_selected).pack(side=tk.RIGHT, padx=8)
ttk.Button(opts, text="Converter todos", command=self.convert_all).pack(side=tk.RIGHT)
# Resize options
resize = ttk.Labelframe(self, text="Redimensionamento (aplicado antes da paletização)")
resize.pack(side=tk.TOP, fill=tk.X, padx=8, pady=6)
self.resize_enable_var = tk.BooleanVar(value=False)
ttk.Checkbutton(resize, text="Ativar redimensionamento", variable=self.resize_enable_var).pack(side=tk.LEFT, padx=(8,12))
ttk.Label(resize, text="Largura:").pack(side=tk.LEFT)
self.resize_w_var = tk.IntVar(value=256)
ttk.Spinbox(resize, from_=1, to=8192, textvariable=self.resize_w_var, width=6).pack(side=tk.LEFT, padx=4)
ttk.Label(resize, text="Altura:").pack(side=tk.LEFT, padx=(12,0))
self.resize_h_var = tk.IntVar(value=256)
ttk.Spinbox(resize, from_=1, to=8192, textvariable=self.resize_h_var, width=6).pack(side=tk.LEFT, padx=4)
self.keep_aspect_var = tk.BooleanVar(value=True)
ttk.Checkbutton(resize, text="Manter proporção (encaixar em LxA)", variable=self.keep_aspect_var).pack(side=tk.LEFT, padx=(12,4))
self.pow2_var = tk.BooleanVar(value=False)
ttk.Checkbutton(resize, text="Arredondar para potência de 2", variable=self.pow2_var).pack(side=tk.LEFT, padx=(12,4))
# Main split: left list, right preview/inspect
main = ttk.Frame(self)
main.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=(0,8))
self.listbox = tk.Listbox(main, selectmode=tk.EXTENDED)
self.listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
self.listbox.bind("<<ListboxSelect>>", self.on_list_select)
right = ttk.Frame(main)
right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(8,0))
right_top = ttk.Frame(right)
right_top.pack(side=tk.TOP, fill=tk.X)
ttk.Button(right_top, text="Inspecionar TGA", command=self.inspect_current).pack(side=tk.LEFT)
ttk.Button(right_top, text="Pré-visualizar", command=self.update_preview).pack(side=tk.LEFT, padx=6)
self.meta_text = tk.Text(right, height=8, wrap="word")
self.meta_text.pack(side=tk.TOP, fill=tk.X, pady=(6,6))
self.preview_canvas = tk.Canvas(right, bg="#222", height=420)
self.preview_canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
self.status_var = tk.StringVar(value="Pronto.")
status = ttk.Label(self, textvariable=self.status_var, anchor="w")
status.pack(side=tk.BOTTOM, fill=tk.X, padx=8, pady=4)
# ---------- actions ----------
def add_files(self):
paths = filedialog.askopenfilenames(
title="Escolha imagens",
filetypes=[
("Imagens", "*.tga *.png *.jpg *.jpeg"),
("TGA", "*.tga"),
("PNG", "*.png"),
("JPEG", "*.jpg *.jpeg"),
("Todos os arquivos", "*.*"),
]
)
if not paths:
return
added = 0
for p in paths:
pp = Path(p)
if pp.exists():
self.files.append(pp)
self.listbox.insert(tk.END, str(pp))
added += 1
self.status_var.set(f"{added} arquivo(s) adicionados.")
def remove_selected(self):
sel = list(self.listbox.curselection())
if not sel:
return
sel.sort(reverse=True)
for idx in sel:
del self.files[idx]
self.listbox.delete(idx)
self.status_var.set("Removido(s).")
def clear_list(self):
self.files.clear()
self.listbox.delete(0, tk.END)
self.status_var.set("Lista limpa.")
def choose_output_dir(self):
d = filedialog.askdirectory(title="Escolha a pasta de saída")
if d:
self.output_dir_var.set(d)
def on_list_select(self, event=None):
self.update_meta()
# atualiza preview de forma "preguiçosa"
self.update_preview()
def get_current_path(self) -> Optional[Path]:
sel = self.listbox.curselection()
if not sel:
return None
return self.files[sel[0]]
def update_meta(self):
self.meta_text.delete("1.0", tk.END)
p = self.get_current_path()
if not p:
return
info = [f"Arquivo: {p.name}", f"Tamanho: {p.stat().st_size} bytes"]
# Se TGA, tenta header
if p.suffix.lower() == ".tga":
try:
H = read_tga_header(p)
info.append(f"Dimensões: {H['width']}x{H['height']}")
info.append(f"BPP: {H['pixel_depth']} | Tipo: {H['img_type']} ({IMG_TYPES.get(H['img_type'],'?')})")
info.append(f"Origem: {'TOP' if H['origin_top'] else 'BOTTOM'}-{'LEFT' if H['origin_left'] else 'RIGHT'} | Alpha bits: {H['alpha_bits']}")
info.append(f"ColorMap: type={H['cmap_type']} len={H['cmap_len']} entry_bits={H['cmap_entry_bits']}")
except Exception as e:
info.append(f"Falha lendo header TGA: {e}")
self.meta_text.insert("1.0", "\n".join(info))
def update_preview(self):
p = self.get_current_path()
if not p:
return
try:
im = load_image_any(p)
im = apply_transformations(
im,
rotate_deg=int(self.rotate_var.get() or 0),
flip_vertical=bool(self.flip_var.get())
)
im = apply_resize(
im,
enable=bool(self.resize_enable_var.get()),
target_w=int(self.resize_w_var.get() or 256),
target_h=int(self.resize_h_var.get() or 256),
keep_aspect=bool(self.keep_aspect_var.get()),
round_pow2=bool(self.pow2_var.get())
)
# para preview ficar mais próximo da saída, convertemos para P (palette)
im_prev = convert_to_tga8(im, colors=int(self.colors_var.get() or 256))
# render no canvas com ImageTk
self.draw_image_on_canvas(im_prev)
self.status_var.set(f"Pré-visualização: {im_prev.size[0]}x{im_prev.size[1]} pixels.")
except Exception as e:
messagebox.showerror("Erro na pré-visualização", str(e))
def draw_image_on_canvas(self, im: Image.Image):
# Ajusta ao canvas mantendo aspecto
cw = self.preview_canvas.winfo_width() or 800
ch = self.preview_canvas.winfo_height() or 420
w, h = im.size
if w == 0 or h == 0:
return
scale = min(cw / w, ch / h)
new_w = max(1, int(w * scale))
new_h = max(1, int(h * scale))
im_resized = im.resize((new_w, new_h), Image.NEAREST)
self.preview_image_tk = ImageTk.PhotoImage(im_resized)
self.preview_canvas.delete("all")
x = (cw - new_w) // 2
y = (ch - new_h) // 2
self.preview_canvas_img = self.preview_canvas.create_image(x, y, image=self.preview_image_tk, anchor="nw")
def inspect_current(self):
p = self.get_current_path()
if not p:
messagebox.showinfo("Inspecionar TGA", "Selecione um arquivo TGA na lista.")
return
if p.suffix.lower() != ".tga":
messagebox.showinfo("Inspecionar TGA", "O arquivo selecionado não é .tga.")
return
try:
s = inspect_tga_string(p)
self.show_scroll_dialog("Inspeção TGA", s)
except Exception as e:
messagebox.showerror("Erro na inspeção", str(e))
def show_scroll_dialog(self, title: str, content: str):
win = tk.Toplevel(self)
win.title(title)
win.geometry("600x400")
txt = tk.Text(win, wrap="word")
txt.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
sb = ttk.Scrollbar(win, command=txt.yview)
sb.pack(side=tk.RIGHT, fill=tk.Y)
txt.configure(yscrollcommand=sb.set)
txt.insert("1.0", content)
txt.configure(state="disabled")
def convert_selected(self):
sel = list(self.listbox.curselection())
if not sel:
messagebox.showinfo("Converter selecionados", "Selecione pelo menos um arquivo na lista.")
return
paths = [self.files[i] for i in sel]
self._do_convert(paths)
def convert_all(self):
if not self.files:
messagebox.showinfo("Converter todos", "Nenhum arquivo na lista.")
return
self._do_convert(self.files)
def _do_convert(self, paths: List[Path]):
colors = int(self.colors_var.get() or 256)
rotate_deg = int(self.rotate_var.get() or 0)
flip_vertical = bool(self.flip_var.get())
overwrite = bool(self.overwrite_var.get())
out_dir = Path(self.output_dir_var.get() or ".")
resize_enable = bool(self.resize_enable_var.get())
resize_w = int(self.resize_w_var.get() or 256)
resize_h = int(self.resize_h_var.get() or 256)
keep_aspect = bool(self.keep_aspect_var.get())
round_pow2 = bool(self.pow2_var.get())
ok = 0
errors = []
for p in paths:
try:
dst = convert_file(
p, out_dir,
colors=colors,
rotate_deg=rotate_deg,
flip_vertical=flip_vertical,
overwrite=overwrite,
resize_enable=resize_enable,
resize_w=resize_w,
resize_h=resize_h,
keep_aspect=keep_aspect,
round_pow2=round_pow2
)
ok += 1
except Exception as e:
errors.append(f"{p.name}: {e}")
msg = f"Convertidos: {ok}"
if errors:
msg += f" | Falhas: {len(errors)}\n" + "\n".join(errors[:10])
self.status_var.set(msg)
messagebox.showinfo("Conversão", msg)
def main_gui():
app = SaturnToolGUI()
app.mainloop()
if __name__ == "__main__":
main_gui()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment