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):
Bit | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|
Cor | G | G | G | R | R | R | B | B |
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…
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.
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 | >>3 | 00011100 |
11000000 | >>6 | 00000011 |
= | 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:
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 try
…except
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.
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.
O resultado final é uma imagem com resolução simulada de 256×384 e com 256 cores, nada mal para 1985, né?
Finalizando
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.