Convertendo imagens em 16 cores no MSX – parte 2

Esta é a parte final sobre conversão de imagens para os modos de 16 cores do MSX, a primeira tratou da organização e estrutura destes modos de vídeo como também do processo de redução de cores da imagem para fazê-la “caber” em um modo de vídeo que comporta um número reduzido delas.

Nesta parte, o processo de converter a imagem com 16 cores cores para ser visualizada diretamente em um MSX usando MSX-BASIC.

A paleta de cores do V99x8

A paleta de cores dos V9938 e V9958 é manipulada através do registrador #16 do VDP e opera em conjunto com a porta de E/S 0x9A. Assim, para se alterar o valor da cor de um índice em particular, você envia o índice para o registrador do VDP e escreve na porta de E/S os respectivos valores de vermelho, verde e azul codificados em dois bytes, o primeiro contendo os componentes de vermelho (bits 6 a 4) junto com o azul (bits 2 a 0) e o segundo apenas com o verde (bits 2 a 0).

Após a alteração o registrador do VDP é incrementando e passa a apontar para o índice de cor seguinte (ou para a cor 0 se a anterior era 15) não sendo necessário definir o valor no caso de estar alterando uma sequência de cores.

A paleta de cores no MSX-BASIC

O MSX-BASIC implementa, melhor, reimplementa¹, no comando COLOR a capacidade de manipulação da paleta de cores nos modos de vídeo indexados do V9938 e V9958 com as seguintes sintaxes:

  • COLOR=(«índice», «vermelho», «verde», «azul») → Altera o índice de cor com valores específicos de vermelho, verde e azul. Este comando envia para o VDP a nova cor e também atualiza uma tabela de cópia na VRAM que funciona como uma referência das cores atualmente em uso.
  • COLOR=RESTORE → Restaura as cores do VDP para os valores constantes na tabela de cópia e
  • COLOR[=NEW] → Retorna as cores para os valores “de fábrica” tanto no VDP quanto na tabela de cópia. Por valores “de fábrica” entenda, aquela que tenta simular as cores do TMS91x8.

Isto é tudo o que você precisa saber para alterar a paleta de cores nos MSX2, MSX2+ e MSX turbo R, ou quase pois há alguns detalhes… 😀

Tabela de cópia

A localização desta tabela na VRAM dependerá do modo de vídeo em que o VDP se encontra. Ela ocupa exatos 32 bytes (16×2, né?) e é ordenada do 1º (cor 0) até o 16º (cor 15) índice de cor com o primeiro byte com os valores de vermelho e azul e o segundo byte apenas com o verde. Aliás, a existência desta tabela errante na VRAM não é um capricho do VDP e sim uma necessidade da BIOS visto que a porta de E/S usada para envio dos valores serve apenas para escrita, ou seja, se você não guardar uma cópia destes valores não saberá quais elas são no futuro.

A cor zero

E para finalizar, por padrão, a MSX-BIOS configura os modos de vídeo para exibir 15 cores ao invés de 16 fazendo a cor 0 ser tratada, para fins de compatibilidade com o TMS91x8, como transparente, ou seja, replicando nela a mesma cor utilizada na borda. Isto é feito desligando o o 6º bit do registrador #8 do VDP. Então para termos efetivamente 16 cores na tela é preciso ligar este bit.

(¹) Na atualização do interpretador BASIC para o MSX2 não houve a inclusão de novas palavras reservadas à linguagem e sim a adaptação expansão na sintaxe daquelas já existentes, mesmo que criassem novas e curiosas sintaxes.

Imagem de exemplo

Para testar a rotina de exportação separei (quase que no chute) uma imagem e redimensionei para 512×384 pixels.

E não custa nada lembrar que estou trabalhando com este número de linhas para manter o aspecto 4:3 da tela.

Convertendo a imagem para o MSX

Diferente do que foi feito com o modo de 256 cores, a visualização propriamente dita das imagens de 16 cores no MSX requer dois arquivos, um deles com a imagem propriamente dita carregada na VRAM e o outra consistindo da paleta de cores montada para ela.

Criando uma lista de índices das cores

A rotina da primeira parte apenas manipulava a imagem dentro do objeto do PIllow, então é necessário modificar um pouco a função reduce_colors() para que retorne a tela no formato dos índices da paleta.

def reduce_colors(image, palette, dither=True):

    @cache
    def match_color(pixel):
        return min(
            [
                (idx, distance(pixel, clr))
                for idx, clr in enumerate(palette)
            ],
            key=color_value,
        )[0]

    bitmap = image.load()

    screen = DivisibleList()

    for y in range(image.height):
        line = []
        for x in range(image.width):
            pixel = bitmap[x, y]
            index = match_color(pixel)
            bitmap[x, y] = palette[index]

            line.append(index)

            if dither:
                error = [
                    old - new
                    for old, new in zip(
                        pixel, palette[index]
                    )
                ]
                add_noise(bitmap, x, y, error)

        screen.append(line)

    return screen

A principal mudança foi transformar o trecho que buscava a melhor cor dentro da paleta em uma função² própria, a match_color(), permitindo assim o uso do decorador cache³ ao invés de implementar meu cache utilizando um dicionário. Outra é a criação de uma lista, a “screen”, que recebe os índices dos pixels linha a linha.

Já a DivisbleList é mais um pouco da minha própria ração de cachorro e consiste na uma customização da lista que permite o uso dos operadores de divisão (“/” e “//”) para produzir listas menores.

>>> from divisiblelist import DivisibleList
>>> lista = DivisibleList([i for i in range(12)])
>>> lista
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
>>> for i in lista / 3: print(i)
... 
[0, 1, 2, 3]
[4, 5, 6, 7]
[8, 9, 10, 11]

Aliás, esta mudança não quebra o código apresentado na primeira parte mas mesmo assim fiz uma pequena mudança no “reducing.py”.

__ = reduce_colors(image, palette, dither=False)

Para refletir o fato que a função retorna alguma coisa mas que ela não é utilizada.

Exportando a imagem para o MSX

Dos arquivos que precisam ser criados, o primeiro deles é a paleta de cores e que, para facilitar um pouco, será organizada da mesma forma como na tabela de cópia da VRAM (já explico). Isto é feito com a função convert_palette().

def convert_palette(palette):

    data = bytearray()

    for r, g, b in palette:
        r //= 2
        g //= 32
        b //= 32
        data += bytes([r + b, g])

    return data

A função interage com as 16 cores da paleta, reduz os valores para 3 bits e os organiza em uma sequência de dois bytes contendo vermelho e azul no primeiro e o verde no segundo byte. O componente vermelho é dividido apenas por 2 pois não faz sentido dividi-lo por 32 para multiplicá-lo por 16 depois.

O motivo de fazer é assim é permitir usar apenas o comando COLOR=RESTORE para trocar a paleta de cores da tela.

A imagem exige um pouco mais de trabalho, começando com o caso dela ter o dobro de linhas (modo entrelaçado), ou seja, linhas pares e ímpares precisam ser separadas para páginas distintas e isto é feito pela split_pages().

def split_pages(screen):

    first_page, second_page = [], []

    for even_line, odd_line in screen / {2}:
        first_page.append(even_line)
        second_page.append(odd_line)

    return first_page, second_page

Como a imagem está em uma DivisibleList ela pode ser facilmente “dividida” em pequenas listas de dois elementos cada e que equivalem a uma linha par e outra ímpar. Esta é a função das chaves (“{ … }”) na DivisibleList, definir um número de itens para as listas geradas na divisão.

Outra função é a convert_to_vram() que a cuida de organizar a imagem no leiaute dos modos de vídeo de 16 cores na VRAM do MSX.

def convert_to_vram(screen):

    vram = bytearray()

    for line in screen:
        vram += bytearray(
            (
                even * 16 + odd
                for even, odd in DivisibleList(line) / {2}
            )
        )

    return vram

Neste caso cada linha da imagem é convertida em uma DivisibleList para ser dividida em pedacinhos com dois elementos contendo os pixels para coluna par e ímpar, daí é juntá-los para formar o byte.

Tanto a paleta como também as páginas de vídeo precisam ser armazenadas em um padrão que o MSX-BASIC suporte, isto é feito através da função bsave().

def bsave(filename, data, start=0):

    size = len(data) - 1
    stop = start + size

    with open(filename, "wb") as f:
        f.write(b"\xfe")
        for i in (start, stop, start):
            f.write(i.to_bytes(
                length=2, byteorder="little")
            )
        f.write(data)

Tanto bsave() como split_pages() são reimplementações de funções escritas para a conversão de imagens de 256 cores.

Convertendo a imagem de exemplo

Com todas as funções auxiliares criadas é possível construir um conversor, o “converting.py”.

from PIL import Image
from functions import (
    bsave,
    convert_palette,
    convert_to_vram,
    count_colors,
    quantize_colors,
    reduce_colors,
    split_pages,
)

MAX_LINES = 256

image = Image.open("sample_512x384.png")

mode = "7" if image.width == 512 else "5"
colors = count_colors(image)
palette = quantize_colors(colors, 16)

screen = reduce_colors(image, palette, dither=True)

pages = (
    split_pages(screen)
    if image.height > MAX_LINES
    else (screen, None)
)

for number, data in enumerate(pages):
    if data:
        bsave(f
            "SAMPLE.P{mode}{number}",
            convert_to_vram(data)
        )

bsave(f"SAMPLE.P{mode}L", convert_palette(palette))

image.show()

Ele recebe um arquivo em PNG, ou outro formato suportado pelo Pillow, o converte para 16 cores e salva tanto um arquivo binário com a paleta de cores (“*.P[57]L”) como as páginas de vídeo (“*.P[57][01]”).

Este programa foi feito para imagens em 256×192 e também 512×384 mas também funcionará com imagens em 256×384 e 512×192.

(²) A função foi colocada dentro para justamente aproveitar que a paleta de cores encontra-se no escopo. Em um código melhor organizado isto realmente deveria ser uma classe.

(³) Que era chamado de lru_cache até a versão 3.8 do Python

Visualizando a imagem no MSX

Por último, o programa em MSX-BASIC para carregar a imagem de exemplo, ou qualquer outra em 512×384, já convertida no MSX, o “LOADER7.BAS”.

10 COLOR 15,0,0:SCREEN 7,,,,,2
15 F$="SAMPLE.P7"
20 VDP(1)=VDP(1) AND &HBF
25 VDP(9)=VDP(9) OR &H20
30 BLOAD F$+"L",S,64128!
35 COLOR=RESTORE
40 FOR I%=0 TO 1:SET PAGE I%,I%
45 BLOAD F$+CHR$(48+I%),S
50 NEXT I%
55 VDP(10)=VDP(10) AND &H7F
60 VDP(1)=VDP(1) OR &H40
65 K$=INPUT$(1):SCREEN ,,,,,0

A manipulação no registrador #1 do VDP é para desligar (linha 20) e depois ligar (linha 60) a geração da imagem. A linha 25 faz altera o registrador #8 (sim, no programa está 9) para que a cor 0 seja uma cor distinta ao invés da cor da borda. A paleta é carregada na linha 30 e na 35 ela é enviada para o VDP. As páginas da imagem são carregadas entre as linhas 40 e 50. E antes de religar a geração do vídeo o VDP é colocado em modo de 192 linhas alterando o registrador #9 (sim, no programa está 10).

A inconsistência no número dos registradores se deve ao fato de que originalmente o MSX-BASIC identificava, através do comando VDP(), os 8 registradores de modo do VDP do TMS91x8 como de #0 a #7 e o de status como #8. Mas com o MSX2 os registradores do V9938 a partir do #8 receberam um “+1” para não quebrar a compatibilidade.

Considerações finais

E antes de terminar, um detalhe, como a rotina cria a paleta e a que reduz as cores são independentes, é possível aplicar paletas específicas às imagens como, por exemplo aplicar a paleta de cores “padrão” do MSX, que produz um resultado até interessante.

Os programas e as imagens citados aqui encontram-se no repositório git deste blog e com um brinde, o programa “LOADER5.BAS” que permite carregar imagens de 256×192 como a da primeira parte.

Até!