Convertendo imagens para MSX

imagens-msx-1_abertura

Isto aqui é uma sequência indireta da publicação sobre criação de imagens em Python através da biblioteca Pillow. Mas desta vez a ideia é utilizá-la não para criar novas imagens mas para para converter as já existentes para o modo de 256 cores dos MSX2 e  no “bom e velho” formato binário do MSX-BASIC.

Motivo? Apesar de interessante o modo de vídeo de 256 cores sempre foi meio negligenciado.

E para o assunto não ficar — muito — chato farei em etapas, acrescentando um elemento de cada vez e dando o máximos de explicação sobre as coisas que estão acontecendo, então…

O modo de 256 cores do MSX2

TL;DR Este modo de cores usa um RGB assimétrico com 3 bits para vermelho e 3 bits para verde e somente 2 bits para azul.

Lançado no ano de 1985 o MSX2 incluía entre as novidades um modo de vídeo que exibia até 256 cores simultâneas na tela em resolução horizontal de 256 pixeis e com 192/212 linhas ou então de 384/424 em modo entrelaçado, algo surpreendente para um equipamento doméstico.

O processador de vídeo do MSX2, o V9938 da Yamaha, consegue exibir 512 cores distintas dentro de uma paleta de 9 bits¹ (alocando 3 bits para cada componente de cor do RGB) e que podem ser utilizadas livremente nos modos de vídeo de dois², quatro e dezesseis cores.

Mas o modo de 256 cores usa uma paleta estática (até mesmo para os sprites) com cada bit associado aos componentes do RGB desta maneira (eu não errei, o verde vem na frente):

Bit76543210
CorGGGRRRBB

Isto foi feito para fazer com que cada pixel do modo de 256 cores ocupasse também um byte da memória de vídeo. E assim o componente de cor azul sofreu uma redução de 50% na sua resolução e fazendo o  “cubo” RGB neste modo de vídeo virar um paralelepípedo…

imagens-msx-1_cubo-rgb

Mas por qual motivo foi a cor azul escolhida? Simples, ela é menor identificada pelo olho humano.

Só para concluir, como cada cada pixel é um byte e estes estão organizados de forma linear na VRAM a localização de cada um pode ser facilmente calculada como:

endereço da VRAM = posição Y × 256 + posição X

Claro, isto desconsiderando o modo entrelaçado onde linhas pares e ímpares ficam cada uma em uma página (mas isto não é uma preocupação no momento)… 🙂

(¹) Inclusive é a mesma resolução de cores do Atari ST, lançado no mesmo ano.

(²) Considerando que a SCREEN 0 em 40×24 é um modo de vídeo que só se consegue exibir duas cores no máximo.

A imagem de exemplo

Para testar a rotina de conversão, separei uma imagem e apenas a redimensionei para o tamanho da tela do MSX, ou seja, 256×192 pixeis.

imagens-msx-1_original

Sim, eu sei que a resolução da SCREEN 8 é de 256×212 por padrão mas acontece que é possível alterar facilmente o número de linhas e assim usar um aspecto de 4:3 na tela.

Funções básicas

Antes de seguir para a conversão propriamente dita, algumas funções que serão úteis no processo.

Redução do RGB

O modo mais simples de converter a imagem para o MSX2 é simplesmente pegando o valor RGB em 24-bit do PNG e reduzindo-o para a resolução de 8-bit para fazer com que ele caiba no espaço de cores do V9938. Isto equivale a pegar os valores de vermelho e verde  e dividir por 32 e por 64 para o azul.

def reduce_color(r, g, b, *args):
    r &= 0xE0
    g &= 0xE0
    b &= 0xC0
    return r, g, b

Tudo que a função faz é ajustar as cores de cada pixel lido e, como também estou atualizando as cores na imagem original, ao invés de dividir para depois multiplicar pelo mesmo número optei por usar a operação lógica E binário (“&”) neles.

O argumento “*args” está ai para receber o canal de transparência caso a imagem o possua.

Do RGB para a VRAM do MSX

Para converter os valores RGB do pixel para um byte da VRAM do MSX, um pouco mais de operadores binários.

def to_msx2_rgb(r, g, b):
    return g | r >> 3 | b >> 6

Ele é montado junto operações de deslocamento de bits para a direita (“>>”) com o OU binário (“|”) e sendo um pouco mais  visual…

11100000 11100000
11100000>>300011100
11000000>>600000011
 =11111111

O deslocamento de 3 e 6 bits para a direita são equivalentes a divisão do número por 8 (23) e 64 (26), respectivamente, mas desta forma fica bem mais claro de entender o que está acontecendo.

Aliás, não estou “aparando” os valores pois isto foi feito antes pela função reduce_color(), logo não faz sentido fazer a mesma coisa duas vezes.

Salvando o arquivo binário

Por último, uma função para salvar o conteúdo da VRAM em um arquivo no formato binário do MSX.

def bsave(filename, vram):
   vram_size = len(vram) - 1
   header = bytearray(
        (
            0xFE,
            0x00, 0x00,
            vram_size % 256, vram_size // 256,
            0x00, 0x00,
        )
    )        
    with open(filename, "wb") as f:
        f.write(header)
        f.write(vram)

Tudo o que a função faz é montar o cabeçalho de sete bytes para arquivos binários — o byte 0xFE que o identifica seguido dos endereços inicial, final e de execução — e gravá-lo junto com o conteúdo da VRAM em um arquivo.

E aqui vou pular uma discussão sobre endianness… 😉

O programa de conversão

Agora já é possível experimentar a conversão da imagem trabalhando apenas com a redução de cores.

from PIL import Image
from functions import bsave, reduce_color, to_msx2_rgb

image = Image.open("sample_256x192.png")
bitmap = image.load()
vram = bytearray()

for y in range(192):
    for x in range(256):
        old_pixel = bitmap[x, y]
        new_pixel = reduce_color(*old_pixel)
        bitmap[x,y] = new_pixel
        vram.append(to_rgb_msx(*new_pixel))

image.show()
bsave("reduced.sc8", vram)

O programa (“reducing.py”) carrega a imagem e uma sequência de laços for percorre todos os pixeis dela aplicando a redução de cores e armazenando o valor obtido em uma array de bytes. No final a imagem obtida é exibida na tela e também salva como um arquivo binário de MSX.

Para ver no MSX

Para carregá-la em um MSX2 bastam algumas linhas de MSX-BASIC.

10 COLOR 15,0,0
20 SCREEN 8:VDP(10)=VDP(10) AND &H7F
30 BLOAD "REDUCED.SC8",S
40 K$=INPUT$(1)

O comando “VDP(10)=VDP(10) AND &H7F” reduz a quantidade de linhas de 212 para 192 desligando o valor de LN no bit 7 do registrador 9 (que no MSX-BASIC é acessado como sendo o registrador 10).

O resultado é este aqui:

imagens-msx-1_color-reducing

Claro que a redução arbitrárias das cores não produz um bom resultado por conta de pedaços da imagem que acabam ficando com a mesma tonalidade de cor, também chamada de color banding.

Aplicando dithering na imagem

Em computação gráfica o dithering, ou matização, é uma técnica que aplica intencionalmente ruído a uma imagem a fim de criar uma variação de tons entre os pixeis e assim simular uma cor que não existe ali e minimizando o color banding — o resto do truque fica por conta do olho humano.

Implementando Floyd-Steinberg

E escolhi implementar o Floyd-Steinberg³, nele a diferença entre a cor original (a do PNG) e da cor obtida (a da redução) é espalhada de maneira uniforme ao redor dos pixeis vizinhos.

Assim, ao arquivo de funções básicas, adiciona-se um novo integrante.

FLOYD_STEINBERG_NEIGHBORS = (
    (+1, 0, 7),
    (-1, +1, 3), (-1, +1, 5), (+1, +1, 1),
)

def add_noise(bitmap, x, y, error):
    for offset_x, offset_y, debt in FLOYD_STEINBERG_NEIGHBORS:
        try:
            off_x, off_y = x + offset_x, y + offset_y
            bitmap[off_x, off_y] = tuple(
                (
                    color + error * debt // 16
                    for color, error in zip(
                        bitmap[off_x, off_y], error
                    )
                )
            )
        except IndexError:
            ...

Ela cuida de aplicar o dithering, recebendo o objeto PixelAccers, as coordenadas do pixel e q quantificação do erro (que é uma lista com estes valores para R, G e B), daí faz a distribuição deste nos pixeis vizinhos de acordo com o Floyd-Steinberg — tudo sendo feito dentro de um tryexcept já que algumas coordenadas estarão fora da área imagem.

Alterando o programa de conversão

O programa “dithering.py” é baseado no anterior, e contém algumas diferenças com primeiro, uma é que ele importa a nova função.

from functions import (
    add_noise,
    bsave,
    reduce_color,
    to_msx2_rgb
)

Acrescenta sua chamada ao final do laço principal.

for y in range(192):
    for x in range(256):
        ...
        error = [old - new for old, new in zip(old_pixel, new_pixel)]
        add_noise(bitmap, x, y, error)

E substituir o nome do arquivo produzido.

bsave("dithered.sc8", vram)

Após a execução a imagem resultante é esta.

imagens-msx-1_color-dithering

Um resultado bem mais próximo da imagem original.

Pra ver no MSX

Claro, o programa em MSX-BASIC para carregar a imagem.

10 COLOR 15,0,0
20 SCREEN 8:VDP(10)=VDP(10) AND &H7F
30 BLOAD "DITHERED.SC8",S
40 K$=INPUT$(1)

É o mesma código usado antes só que com um nome de arquivo diferente, óbvio! 🙂

(³) Minha implementação é quase a transcrição do exemplo em pseudocódigo no tópico da Wikipédia para Python.

O modo de vídeo entrelaçado

TL;DR São duas páginas de vídeo uma com as linhas pares e outra com as ímpares que se alternam sem parar. A função split_pages() cuidará de separá-las para salvá-las separadamente.

O VDP do MSX2 tem a sua disposição 128 KiB de RAM que ele utiliza para armazenar tanto a tela em si como também os padrões e atributos dos sprites. Do ponto de vista funcional esta memória fica organizada em 2 ou 4 páginas dependendo da largura da tela e quantidade de cores utilizada. Na verdade o VDP trabalha apenas com números “redondos”, ou seja, as páginas tem tamanho de 32 KiB (SCREEN 5 e SCREEN 6) ou então 64 KiB (SCREEN 7 e SCREEN 8) e enquanto uma página é exibida as demais ficam ocultas mas sendo possível alterná-las a qualquer momento.

O modo entrelaçado é um truque uma técnica para exibir duas páginas de vídeo de forma intercalada e com uma pequena diferença de altura entre elas para simular o dobro de linhas em dispositivos de vídeo que não possuem a largura de banda para tal (o inconveniente era fazer a tela piscar “um pouco”).

Além do MSX, os modos entrelaçados eram bastante usados no Amiga e até nos primeiros monitores VGA para exibir a resolução de 1024×768 (meu primeiro monitor era assim) e até em aparelhos tocadores de DVD (480i).

No caso específico do MSX, a imagem está separada em duas páginas de vídeo uma contendo somente as linhas pares e outra somente as ímpares.

Separando as páginas de vídeo

Para fazer a conversão da imagens neste modo é preciso que deixá-la linear para justamente manipular os vizinhos com mais facilidade e só separá-la em duas páginas no final do processo.

def split_pages(vram, width=256):
    page_0, page_1 = bytearray(), bytearray()
    vram_size = len(vram) - 1
    blocks = width * 2

    line = 0
    while line < vram_size:
        page_0 += vram[line : line + width]
        page_1 += vram[line + width : line + block]
        line += blocks

    return page_0, page_1

A função split_pages() cuida desta separação das páginas de vídeo, literalmente, fatiando o bytearray que compõe a VRAM.

Convertendo para o modo entrelaçado

O arquivo “interlacing.py” é baseado no “dithering.py” e agora as alterações são são (quase) mínimas (aqui a imagem foi trocada por uma versão já com a resolução 256×384).

from PIL import Image
from functions import (
    add_noise,
    bsave,
    reduce_color,
    split_pages,
    to_msx2_rgb
)
image = Image.open("sample_256x384.png")
...
for y in range(384):
    ...

image_0, image_1 = split_pages(vram)
bsave("interlac.s80", image_0)
bsave("interlac.s81", image_1)

E é quase a mesma coisa no programa que cuida de carregar a imagem no MSX, exceto que agora faz duas imagens.

10 COLOR 15,0,0:SCREEN 8,,,,,2
20 FOR I%=0 TO 1:SET PAGE I%,I%:CLS
30 BLOAD "INTERLAC.S8"+CHR$(48+I%),S
40 NEXT I%
50 VDP(10)=VDP(10) AND &H7F
60 K$=INPUT$(1):SCREEN ,,,,,0

O comando SET PAGE serve para definir qual página de vídeo será usada e o modo entrelaçado é ligado através do comando instrução SCREEN seguida por quase meia dúzia de vírgulas para ativar o modo entrelaçado.

imagens-msx-1_interlaced

O resultado final é uma imagem com resolução simulada de 256×384 e com 256 cores, nada mal para 1985, né?

Finalizando

imagens-msx-1_comparacao

Para finalizar, um comparativo utilizando uma outra fotografia, a original do lado esquerdo e a convertida para MSX do lado direito e ambas em resolução de 256×384 pixeis. O modo de 256 cores do MSX é, dos “modos de video de fotorrealismo” disponíveis na linha MSX aquele que é mais fácil de trabalhar, manipular e mesmo com o erro induzido pelo Floyd-Steinberg o que produz menos perda de qualidade na imagem.

E os programas (em uma versão bem mais comentada) e as imagens utilizadas aqui estão no repositório no repositório git do blog.