Created
September 28, 2025 03:51
-
-
Save celsowm/1635011255a482343bbda6848d932bec to your computer and use it in GitHub Desktop.
saturn_image_tool.py for Jo Engine
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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