#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ LMSK Encoder (Python) - based on LMSK spec + reference C encoder. Features: 1) Input: image path (PNG/BMP/JPG/...) 2) If image has alpha: extract alpha channel -> compress to .lmsk 3) If image has no alpha: convert to grayscale (8-bit) -> compress to .lmsk 4) Optional output path; if omitted -> same directory, same stem + .lmsk 5) Raw mode off by default; enable via --raw 6) Disable gradient algorithm via --no-gradient 7) Set gradient tolerant (0~3) via --gradient-tolerant """ import argparse import struct import zlib from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional, Sequence, Tuple try: from PIL import Image except ImportError: raise SystemExit("Missing dependency: Pillow. Install with: pip install pillow") # ----------------------------------------------------------------------------- # Constants (aligned with __lmsk_common.h) # ----------------------------------------------------------------------------- ARM_LMSK_VERSION_MAJOR = 1 ARM_LMSK_VERSION_MINOR = 2 ARM_LMSK_VERSION = ((ARM_LMSK_VERSION_MAJOR & 0xF) << 4) | (ARM_LMSK_VERSION_MINOR & 0xF) TAG_U8_ALPHA = 0xFD TAG_U8_GRADIENT = 0xF9 END_MARK = 0xDEADBEEF FLOOR_SIZE = 1 << 16 # tagSetBits=0 => 65536 # ----------------------------------------------------------------------------- # Helpers: header packing (16 bytes) # ----------------------------------------------------------------------------- def pack_header(width: int, height: int, alpha_bits: int, raw: bool, floor_count: int, tagset_bits: int = 0) -> bytes: """ Pack arm_lmsk_header_t (16 bytes). Layout based on spec and __lmsk_common.h: chName[5] = "LMSK\0" Version = (major<<4)|minor iWidth = int16 iHeight = int16 settingBits= u3AlphaMSBCount:3 | bRaw:1 | u2TagSetBits:2 | reserved:2 chFloorCount= uint8 reserved32 = uint32 """ if not (1 <= alpha_bits <= 8): raise ValueError("alpha_bits must be 1..8") if not (-32768 <= width <= 32767 and -32768 <= height <= 32767): raise ValueError("width/height must fit int16") u3AlphaMSBCount = (alpha_bits - 1) & 0x7 setting_byte = (u3AlphaMSBCount | ((1 if raw else 0) << 3) | ((tagset_bits & 0x3) << 4)) return (b"LMSK\0" + struct.pack(" int: """ Compute delta in MSB domain, choosing the smaller absolute value with wrap-around, matching reference C encoder __arm_lmsk_get_delta(). """ shift = 8 - alpha_bits prev_m = prev >> shift curr_m = curr >> shift delta0 = curr_m - prev_m mod = 1 << alpha_bits if delta0 < 0: delta1 = (curr_m + mod) - prev_m return delta0 if abs(delta0) < abs(delta1) else delta1 elif delta0 > 0: delta1 = (prev_m + mod) - curr_m return delta0 if abs(delta0) < abs(delta1) else -delta1 else: return 0 # ----------------------------------------------------------------------------- # Encoding result model # ----------------------------------------------------------------------------- @dataclass class EncodeResult: hit: bool raw_size: int enc: bytes new_prev: int check_palette: bool name: str # ----------------------------------------------------------------------------- # Palette helper (mimics reference encoder strategy with sentinel 0 after slot0) # ----------------------------------------------------------------------------- def palette_find_or_insert(palette: List[int], alpha: int) -> Optional[int]: """ Reference encoder strategy: - search for exact alpha - stop early if palette[i]==0 and i>0 (sentinel = first free) - if not found and free exists -> insert and return index """ idx = 0 for idx in range(32): if palette[idx] == alpha: return idx if palette[idx] == 0 and idx > 0: break if idx < 32: palette[idx] = alpha return idx return None # ----------------------------------------------------------------------------- # Try functions: ALPHA / DELTA_LARGE / DELTA_SMALL / REPEAT / GRADIENT # ----------------------------------------------------------------------------- def try_alpha(source: Sequence[int], size_left: int, prev: int, alpha_bits: int, palette: List[int], tolerant: int, actual_prev: int) -> EncodeResult: val = source[0] return EncodeResult(True, 1, bytes([TAG_U8_ALPHA, val]), val, True, "ALPHA") def try_delta_large(source: Sequence[int], size_left: int, prev: int, alpha_bits: int, palette: List[int], tolerant: int, actual_prev: int) -> EncodeResult: d = get_delta(prev, source[0], alpha_bits) if -32 <= d <= 31: b = ((d & 0x3F) << 2) | 0x03 # tag bits 0b11 shift = 8 - alpha_bits new_prev = (source[0] >> shift) << shift return EncodeResult(True, 1, bytes([b]), new_prev, False, "DELTA_LARGE") return EncodeResult(False, 0, b"", prev, False, "DELTA_LARGE") def try_delta_small(source: Sequence[int], size_left: int, prev: int, alpha_bits: int, palette: List[int], tolerant: int, actual_prev: int) -> EncodeResult: if size_left < 2: return EncodeResult(False, 0, b"", prev, False, "DELTA_SMALL") d0 = get_delta(prev, source[0], alpha_bits) if not (-4 <= d0 <= 3): return EncodeResult(False, 0, b"", prev, False, "DELTA_SMALL") d1 = get_delta(source[0], source[1], alpha_bits) if not (-4 <= d1 <= 3): return EncodeResult(False, 0, b"", prev, False, "DELTA_SMALL") # tag bits 0b10, d0 in bits[4:2], d1 in bits[7:5] b = 0x02 | ((d0 & 0x7) << 2) | ((d1 & 0x7) << 5) shift = 8 - alpha_bits new_prev = (source[1] >> shift) << shift return EncodeResult(True, 2, bytes([b]), new_prev, False, "DELTA_SMALL") def try_repeat_prev(source: Sequence[int], size_left: int, prev: int, alpha_bits: int, palette: List[int], tolerant: int, actual_prev: int) -> EncodeResult: shift = 8 - alpha_bits prev_m = prev >> shift count = 0 for i in range(size_left): if (source[i] >> shift) != prev_m: break count += 1 if count == 0: return EncodeResult(False, 0, b"", prev, False, "REPEAT") if count <= 62: # tag bits 0b01; u6Repeat = count-1 b = 0x01 | ((count - 1) << 2) return EncodeResult(True, count, bytes([b]), prev, False, "REPEAT") # Reference encoder uses GRADIENT tag for extra-long repeat (>62) enc = struct.pack(" EncodeResult: """ Ported from __arm_lmsk_try_gradient_tag (C). Note: This is the "gradient detection algorithm"; can be disabled by --no-gradient. """ if size_left < 4: return EncodeResult(False, 0, b"", prev, False, "GRADIENT") # initial iGradientSize = 1 iPrevious = prev iCurrent = source[0] idx = 1 left = size_left - 1 chToAlpha = iCurrent iDeltaPrevious = iCurrent - iPrevious iPrevious = iCurrent step_len_current = 2 step_len_prev = 0 first_step_len = True prev_step_to = 0 prev_step_size = 0 step_count = 0 bNewStep = False while left > 0: iCurrent = source[idx] idx += 1 left -= 1 iDelta = iCurrent - iPrevious iDeltaChange = iDelta - iDeltaPrevious if abs(iDeltaChange) > 1: break if iDeltaChange == 0 or bNewStep: step_len_current += 1 bNewStep = False else: if first_step_len: first_step_len = False else: if abs(step_len_current - step_len_prev) > tolerant: break prev_step_to = iPrevious prev_step_size = iGradientSize step_count += 1 step_len_prev = step_len_current step_len_current = 1 bNewStep = True # wrong direction if iDelta < 0 < iDeltaPrevious: break if iDelta > 0 > iDeltaPrevious: break iDeltaPrevious = iDelta chToAlpha = iCurrent iPrevious = iCurrent iGradientSize += 1 # resume to previous step if needed if step_count > 0: chToAlpha = prev_step_to iGradientSize = prev_step_size if iGradientSize > 4: # case 1: prev equals actual previous pixel -> gradient tag directly if prev == actual_prev: steps = iGradientSize - 1 # count = pixels-1 enc = struct.pack(" insert INDEX/ALPHA + gradient if iGradientSize > 6: start_pixel = source[0] # actual first pixel of gradient segment # Search/insert to palette (side effect allowed, matches C encoder) idx_pal = palette_find_or_insert(palette, start_pixel) raw_size = iGradientSize grad_pixels = iGradientSize - 1 steps = grad_pixels - 1 # = iGradientSize - 2 grad_enc = struct.pack(" bytes: width = len(row) out = bytearray() # first pixel raw out.append(row[0]) prev = row[0] pos = 1 left = width - 1 # order aligned with C: DELTA_LARGE, REPEAT, DELTA_SMALL, GRADIENT, ALPHA algs = [try_delta_large, try_repeat_prev, try_delta_small, try_gradient, try_alpha] while left > 0: source = row[pos:] actual_prev = row[pos - 1] # pchSourceBase[-1] equivalent best: Optional[EncodeResult] = None best_rate = -1.0 for fn in algs: if fn is try_gradient and no_gradient: continue res = fn(source, left, prev, alpha_bits, palette, tolerant, actual_prev) if not res.hit: continue rate = res.raw_size / len(res.enc) if (best is None) or (rate > best_rate) or (rate == best_rate and res.raw_size > best.raw_size): best = res best_rate = rate if best is None: raise RuntimeError("No encoding decision hit (unexpected).") # palette substitution only for ALPHA + raw_size==1 (matches C) if best.check_palette and best.raw_size == 1: idx_pal = palette_find_or_insert(palette, best.new_prev) if idx_pal is not None: index_byte = (idx_pal & 0x1F) << 2 best = EncodeResult(True, 1, bytes([index_byte]), best.new_prev, False, "INDEX") out.extend(best.enc) pos += best.raw_size left -= best.raw_size prev = best.new_prev return bytes(out) # ----------------------------------------------------------------------------- # Whole-image encoding with row dedup + floor handling (matches lmsk_encoder.c) # ----------------------------------------------------------------------------- @dataclass class LineOut: source: bytes encoded: bytes position: int crc32: int def encode_lmsk(mask: bytes, width: int, height: int, raw: bool = False, no_gradient: bool = False, tolerant: int = 1, alpha_bits: int = 8) -> bytes: if raw: # raw mode: header + raw pixels + end mark (no palette/tables) header = pack_header(width, height, alpha_bits=1, raw=True, floor_count=0) return header + mask + struct.pack("> 16 target_floor = found.position >> 16 if current_floor != target_floor: # clone into current floor (matches C) new_line = LineOut(found.source, found.encoded, position, crc) crc_map.setdefault(crc, []).append(new_line) references[y] = position data_section.extend(found.encoded) position += len(found.encoded) else: references[y] = found.position else: encoded = encode_line(row, alpha_bits, palette, no_gradient, tolerant) new_line = LineOut(row, encoded, position, crc) crc_map.setdefault(crc, []).append(new_line) references[y] = position data_section.extend(encoded) position += len(encoded) # build floor table + line index table floor_level = 0 floor_entries: List[int] = [] index_table: List[int] = [0] * height for y in range(height): ref = references[y] if ref - floor_level < 0: raise RuntimeError("Reference before current floor level (should not happen).") if ref - floor_level >= FLOOR_SIZE: floor_level += FLOOR_SIZE floor_entries.append(y) index_table[y] = ref - floor_level if not (0 <= index_table[y] <= 0xFFFF): raise RuntimeError("Line index overflow (must fit uint16).") floor_count = len(floor_entries) if floor_count > 255: raise RuntimeError("Too large: floor_count > 255 (data section >= 16MB).") header = pack_header(width, height, alpha_bits, raw=False, floor_count=floor_count) palette_bytes = bytes(palette) floor_bytes = b"".join(struct.pack(" Tuple[bytes, int, int, bool]: img = Image.open(path) # Detect alpha presence robustly has_alpha = ("A" in img.mode) or (img.mode == "P" and "transparency" in img.info) if has_alpha: img = img.convert("RGBA") alpha = img.getchannel("A") mask = alpha.tobytes() else: img = img.convert("L") mask = img.tobytes() return mask, img.width, img.height, has_alpha # ----------------------------------------------------------------------------- # CLI # ----------------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser( description="Encode image alpha (or grayscale) into LMSK (*.lmsk)." ) parser.add_argument("image", help="Input image path (PNG/BMP/JPG/...).") parser.add_argument("-o", "--output", default=None, help="Output .lmsk full path. If omitted, use input stem + .lmsk in same dir.") parser.add_argument("--raw", action="store_true", help="Enable RAW mode (no compression, no palette/tables). Default: off.") parser.add_argument("--no-gradient", action="store_true", help="Disable gradient detection algorithm.") parser.add_argument("--gradient-tolerant", type=int, default=1, choices=[0, 1, 2, 3], help="Gradient tolerant (0~3). Default: 1.") parser.add_argument("--alpha-bits", type=int, default=8, choices=[1, 2, 3, 4, 5, 6, 7, 8], help="Alpha Bits (1~8). Default: 8.") args = parser.parse_args() in_path = Path(args.image) if not in_path.exists(): raise SystemExit(f"Input file not found: {in_path}") out_path = Path(args.output) if args.output else in_path.with_suffix(".lmsk") if out_path.suffix.lower() != ".lmsk": out_path = out_path.with_suffix(".lmsk") mask, w, h, has_alpha = load_mask_from_image(in_path) # For your current requirements, keep alpha_bits=8 (true lossless for 8-bit alpha/grayscale) alpha_bits = args.alpha_bits blob = encode_lmsk(mask, width=w, height=h, raw=args.raw, no_gradient=args.no_gradient, tolerant=args.gradient_tolerant, alpha_bits=alpha_bits) out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_bytes(blob) kind = "Alpha" if has_alpha else "Grayscale" mode = "RAW" if args.raw else "Compressed" print(f"[OK] {kind} -> LMSK ({mode})") print(f" Input : {in_path}") print(f" Output: {out_path}") print(f" Size : {len(blob)} bytes ({w}x{h})") if __name__ == "__main__": main()