Logo NascimentoIgor Nascimento / Blog
Protocolos

PhotonProtocol: um arquivo atravessando o ar como luz, e o que a câmera real ensinou ao modelo

Uma tela pinta códigos, um celular filma, o arquivo chega com o hash conferido. Sem rede, sem cabo, sem pareamento. Uma conversa sobre um canal que não tem volta, sobre um simulador que foi gentil demais, sobre a decisão de uma linha que impediu qualquer transferência real, e sobre o formato denso que nasceu de ouvir o que o telefone mostrou.

ProtocolosSistemas20 min de leituraIgor Nascimento

O problema, e a restrição da qual tudo decorre

Mover um arquivo de um aparelho para outro com nada além de uma tela e uma câmera. Sem rede, sem cabo, sem pareamento, sem conta, sem servidor. Um lado pinta o arquivo como uma sequência de códigos visuais densos; o outro aponta a câmera; um decodificador transforma as fotos de volta em bytes e confere contra um SHA-256. É o PhotonProtocol, e este texto é sobre o que aconteceu entre a especificação e o primeiro arquivo que de fato chegou a um celular.

A restrição que define o projeto inteiro é uma só: o canal é simplex. O emissor nunca ouve o receptor. Não sabe quando a gravação começou, não sabe quais quadros sobreviveram, não pode retransmitir sob demanda. Cada decisão do formato decorre disso:

O formato em uma passada

Um quadro é uma grade quadrada de células. Cada célula carrega uma forma (uma máscara 4x4 com exatamente 8 de 16 sub-células pintadas, para que toda forma tenha a mesma energia de cor) pintada numa cor de tinta. Quatro a seis bits por célula, conforme o perfil. Em volta da carga útil ficam as estruturas que tornam o quadro legível: quatro padrões de busca nos cantos (o clássico 1:1:3:1:1 do QR, invariante sob perspectiva), uma marca de orientação, um anel de temporização, um anel de calibração, um marcador central e duas faixas de cabeçalho.

Duas dessas estruturas carregam ideias que valem a conversa.

O anel de calibração. Balanço de branco, exposição e matriz de cor de uma câmera derivam continuamente e nunca são informados ao emissor. Então o decodificador não classifica células contra a paleta impressa na especificação. Ele classifica contra referências medidas no mesmo quadro: o anel carrega uma amostra rotulada de todo símbolo do alfabeto, distribuída pelo perímetro inteiro. Na primeira fase, um desvio forte de cor sem nenhum outro defeito custou zero erros no perfil de paleta mais apertada. O anel paga o próprio custo.

O cabeçalho em preto e branco, duas vezes. O cabeçalho é quem declara o perfil, então precisa ser legível sem conhecer o perfil. Ele usa um bit por célula em preenchimento sólido, muito mais robusto que os bits de forma e cor da carga útil, e é duplicado em bordas opostas porque é o ponto único de falha do quadro: um reflexo ou um dedo que apague o topo custaria o quadro inteiro.

Trade-off

Os perfis trocam densidade por robustez, e as taxas de paridade correm ao contrário da intuição: o perfil conservador gasta mais do seu quadro menor em paridade (31%) que o denso (12,5%). Um perfil não é um botão de densidade; é um ponto numa curva de robustez. O P1-conservative existe para sobreviver a uma mão trêmula, uma tela fraca e uma câmera barata, condições em que o P3-dense não decodifica nada.

Fase 1: um simulador que mede, e o que ele não viu

Antes de qualquer câmera real, a camada física foi medida contra um canal sintético com ground truth dos dois lados, para que um resultado pudesse ser atribuído ao classificador e não à correção de erro escondendo o estrago. Uma escada de severidade move perspectiva, reamostragem, borrão, balanço de branco, reflexo, ruído e quantização juntos, porque condições reais não degradam uma de cada vez.

Três achados dessa fase valem registro, e o primeiro é negativo. A margem de confiança do classificador não rastreia o erro. Decodificação por apagamento repara o dobro de bytes que decodificação por erro, e a especificação a recomendava. Mas, na faixa de severidade que decide se uma transferência funciona, a margem (vizinho mais próximo contra o segundo) sinalizava só 8% a 26% das células realmente erradas. O caminho de apagamento era complexidade que comprava quase nada. O segundo: a falha é um penhasco, não uma rampa. Todo perfil vai de zero erro a bem acima do orçamento em dois degraus. Bom para interface (não há meio-termo de "meio funcionou"), ruim como aviso prévio.

O terceiro é um bug que generaliza. Uma sequência de pixels cobrindo [início, início+len) tem centro em início + (len-1)/2; usar len/2 coloca toda medida meio pixel atrasada. São 0,08 célula a seis pixels por módulo, invisível a qualquer tolerância, uniforme nos quatro cantos, e suficiente para arrastar cada amostra um terço de sub-célula em direção ao vizinho. Foi o segundo erro de meio pixel do projeto. Os dois vieram da mesma confusão entre a extensão de um pixel e o centro dele, e nenhum foi pego por ida e volta, porque os dois lados de uma ida e volta podem compartilhar a convenção e ainda estar errados. Pelo mesmo motivo, o módulo de alinhamento foi renderizado uma célula fora do lugar por uma versão inteira: codificador e decodificador partilhavam o erro, e só uma implementação de terceiros teria notado.

Fase 2: nenhuma transferência real funcionou, e a causa era uma linha

Toda tentativa de mover um arquivo de um monitor para um celular falhou. O telefone achava o código, lia o cabeçalho, e não lia as células. Toda foto, toda vez. O modelo da fase 1 dizia que aqueles quadros deviam decodificar com folga.

A resposta foi construir um simulador duro o bastante para discordar da fase 1 e concordar com os telefones. O photon film modela uma câmera de celular apontada para um monitor, com o display e a câmera em relógios separados: obturador rolante, varredura e tempo de resposta do painel, pose e tremor de mão, distorção radial da lente, borrão, luz difusa, matriz de filtro de cor e demosaico, exposição, balanço de branco, nitidez artificial, 4:2:0, cintilação de backlight. A saída é um vídeo que o Chrome aceita como câmera, e os testes de ponta a ponta abrem a página receptora real com essa filmagem. O modelo reproduziu a falha relatada exatamente, estágio por estágio: 755 fotos, código localizado em todas, cabeçalho lido em todas, nenhuma carga útil.

Comparando com o que foi enviado: 9,30% das células erradas, das quais 9,29% erraram a forma e 0,04% a cor. Por cor pintada: vermelho 10%, verde 0%, azul 27%, branco 0%. A cor era lida quase perfeitamente; a forma não; e quais formas falhavam dependia inteiramente da tinta.

O classificador decidia a forma primeiro, pelo luma, e a cor depois. O azul da paleta tem 0,26 do luma do branco. Através de qualquer lente que borre, os vizinhos claros de uma célula espalham luz na metade não pintada dela; para uma célula azul ao lado de uma branca, o que entra é tão claro em luma quanto a própria tinta, e a metade mais clara da célula passa a ser a errada.

Por que a fase 1 não viu

Uma célula de 4 bits não cruza fronteira de byte, então 9,3% de células erradas são 17,7% de bytes errados, contra um raio de correção de 15,7%. Todo quadro ficava um pouco acima. A tabela da fase 1 tinha o perfil conservador lendo 6,9% de células erradas e marcava isso como dentro do orçamento. O orçamento era em bytes e a medição em células. Um byte daquele perfil são duas células: 6,9% de células são 13,3% de bytes, dentro, mas na borda, num quadro de quinze palavras-código que todas precisam decodificar. E a fase 1 nunca perguntou quais células erravam, que é a pergunta que entrega a falha.

A correção: a célula é casada inteira, dezesseis sub-células em três canais, contra um template de cada símbolo medido no anel de calibração do próprio quadro, e depois casada de novo contra templates medidos da própria carga útil, onde há vinte vezes mais evidência e os vizinhos são iguais aos da célula sendo lida. Células erradas nas mesmas capturas: 9,30% para 0,06%.

Outros quatro achados da fase 2 mudaram a especificação e a página:

O telefone de verdade

Depois de tudo isso, um iPhone no Safari, apontado à mão para um monitor 1080p: o P1-conservative transferiu uma imagem de 818 KB em 34 segundos a 10 códigos por segundo (23,7 KB/s) e em 21 segundos a 20 (38,2 KB/s), com o hash conferido. O telefone leu 29 fotos por segundo, 92% daquelas em que achou o código.

Os três perfis mais densos não decodificaram uma foto sequer. A tabela do simulador dizia que o P4-balanced devia ler com folga. As fotos foram guardadas e comparadas com o que foi pintado: as células estão erradas no meio do código e certas nas bordas. Medido bloco a bloco, o interior fica até 0,3 célula fora de onde a homografia dos quatro cantos o coloca, num campo que é zero no perímetro e no centro e máximo entre eles, apontando para dentro. A correção de lente da fase 2 media o perímetro e misturava para dentro; ela não segue esse campo e o piora.

O modelo era gentil onde doía

O simulador dobrava a imagem em torno do centro da lente, que é onde o código está, e portanto dobrava o código quase nada. A correção de lente foi testada contra uma distorção que ela trata, e a linha da tabela que diz que funciona é um teste do modelo. A pergunta aberta Q4 da especificação, "precisa de uma malha de marcas de alinhamento?", tinha sido respondida com "não" depois da simulação. A resposta estava errada. O conservador tem células grandes o bastante para serem lidas 0,3 célula fora. Os outros não.

Fase 3 e o branch experiment/throughput: um formato para a câmera que existe

Com 128 fotos do iPhone e, para cada célula, o que foi pintado e o que foi visto, dava para ouvir o telefone com atenção. Ele disse quatro coisas. A grade está fora no meio e em nenhum outro lugar. O anel de calibração não se parece com a carga útil, porque corre ao lado da margem branca e a luz dela invade os templates. Preto é um quinto de branco, e não o mesmo quinto em toda a foto (95 e 205 de 255, e a parte de baixo da tela mais lavada que a de cima). E brilho é nítido e cor não é: uma borda preto-branco sobe em 1,6 a 2,7 pixels, uma borda de cor leva o dobro. Com o template exato de cada símbolo em mãos, ainda assim 7% a 10% das células do P4-balanced estavam erradas, quase todas em cor. A forma sozinha errava 0,4% a 3%.

As três primeiras foram corrigidas no decodificador dos quadros de células: grade medida pelas bordas das próprias células (a fase das bordas em blocos de 8x8, desdobrada do perímetro para dentro), alfabeto aprendido da própria carga útil, preto e branco por vizinhança. De 33 fotos do P4-balanced que o iPhone tirou e nenhum decodificador lia, 15 passaram a ler. A quarta não tem conserto em decodificador, e é por isso que existe o formato denso. Três decisões, cada uma a leitura literal de algo acima:

  1. Preto e branco. Um módulo é um bit. Uma célula que gasta sua área em cor gasta no que a câmera vê pior. A 3 pixels de câmera por módulo, um quadro do formato da tela tem 600 por 336 módulos.
  2. Tiles. O quadro é cortado em até 56 tiles, cada um com suas palavras-código, seu checksum e a anotação de qual código ele pertence. Uma foto tirada enquanto o código muda rende os tiles que pegou inteiros. O quadro de células perdia a foto inteira nesse caso, porque entrelaçava as palavras-código pelo quadro todo.
  3. Uma marca em cada tile, encontrada diretamente, com as bordas dos módulos para o que fica entre as marcas. É a resposta à grade fora no meio.

Há uma mudança de formato que acompanha isso e que quebra compatibilidade com o rascunho anterior: os bytes são branqueados (XOR com uma sequência xorshift fixa) antes de pintar. Um arquivo que comprime bem termina num quadro que é quase todo padding, e um quadro de zeros é uma célula repetida: não tem branco, tem poucas bordas e uma tinta só. Um decodificador que mede o quadro pelo que há nele não tem o que medir. Branqueado, todo quadro tem todo símbolo mais ou menos na mesma frequência, seja o que for que carregue.

O que ler o formato denso exigiu

Na ordem em que foi descoberto, contra o simulador no ajuste typical, com D3-blaze a 2,5 pixels de câmera por módulo e 30 códigos por segundo:

Os números, e por que ainda não são uma comparação

Através da câmera simulada, um mebibyte de bytes incompressíveis chega em 2,14 segundos a 30 fotos por segundo (479 KB/s) e em 1,15 segundo a 60 (888 KB/s). Através da página receptora real no Chrome, com essa filmagem como câmera, em 2,2 segundos. O Decimen, cujo recorde este branch saiu para bater, moveu um mebibyte entre um monitor de 49 polegadas e um iPhone em 2,45 segundos.

O repositório diz com todas as letras que esses não são o mesmo tipo de número. O deles é dois aparelhos numa sala. O nosso é um modelo de câmera que já errou antes na direção que lisonjeia: disse que os perfis densos da fase 2 liam, e o telefone disse que não. O que a tabela mostra é que o formato e o decodificador têm a capacidade. Se um telefone tem, só o telefone dirá, e a linha da tabela de benchmarks que vai decidir isso está explicitamente vazia.

Os trade-offs do formato denso, ditos antes que alguém descubra

O que eu levo deste projeto para qualquer outro

Um modelo que não reproduz a falha não serve para corrigi-la. A fase 1 tinha um simulador, era determinístico, tinha testes, e descreveu um sistema que não existia. O que destravou o projeto foi tornar o modelo duro o suficiente para falhar como os telefones falhavam, e só então olhar quais células erravam.

Medir a coisa certa na unidade certa. Orçamento em bytes, medição em células: o quadro estava "dentro" e falhava. É o mesmo tipo de erro de medir um codificador em imagem crua quando a pipeline alimenta recorte com letterbox, que discuti no texto sobre o Stratosphere.

Ida e volta não pega erro compartilhado. Dois erros de meio pixel e um módulo de alinhamento fora do lugar passaram por todos os testes porque codificador e decodificador concordavam entre si. A especificação carrega vetores de teste justamente para que uma segunda implementação possa provar compatibilidade sem ler uma linha de Rust.

Duas regras do repositório que eu adotaria em qualquer sistema com formato de arquivo ou de rede: nenhuma mudança no wire format sem atualizar a especificação no mesmo commit, e nenhuma otimização sem benchmark mostrando o ganho. As perguntas abertas da especificação estão abertas porque devem ser resolvidas por medição, e um argumento não é uma medição.

O que eu faria diferente

Apontaria um celular real para a tela no primeiro dia em que houvesse algo para pintar, antes de qualquer simulador. O simulador acabou sendo indispensável, mas a ordem certa é: a realidade mostra a falha, o modelo aprende a reproduzi-la, e só então o modelo ganha o direito de prever. Na ordem inversa, o modelo descreve o que a gente gostaria que acontecesse.

Resumo da conversa