mozak.tech Engenharia de IA Corporativa 94%

Parte II — Acesso Programático e Instrução de Modelos

2.7 — Além do Texto: Visão Computacional via API de Modelos Multimodais (Multimodal APIs)

Objetivo da Aula

Ao concluir esta aula, você será capaz de:

Enviar imagens via URL e base64 para a API Claude Vision e interpretar as respostas

Construir prompts de extração de dados estruturados para documentos e imagens de UI

Implementar parsing robusto de JSON retornado por modelos de visão

Escolher entre análise de URL vs base64 com base em latência, privacidade e casos de uso

Aplicar modelos multimodais a casos práticos: OCR, análise de diagramas, descrição de acessibilidade

Por que isso importa

Antes dos modelos multimodais, processar imagens requeria pipelines separados: OCR para extrair texto, modelos de visão especialistas para classificação, e LLMs separados para gerar texto. Cada componente era um sistema a ser treinado, mantido e integrado.

Claude Opus, GPT-4V e Gemini Pro Vision colapsaram esses pipelines em uma única API. Você envia a imagem e o texto do prompt juntos, e o modelo processa ambos simultaneamente. Isso não é só mais conveniente — é qualitativamente diferente. O modelo entende a relação entre a imagem e o prompt, não apenas analisa a imagem isoladamente.

Os casos de uso práticos são imediatos:

Processamento de documentos: NF-e, contratos, formulários médicos, recibos. Em vez de treinar um modelo de OCR específico para cada tipo de documento, você usa um prompt descrevendo o que quer extrair. A troca de um tipo de documento para outro é mudar o prompt, não re-treinar o modelo.

Análise de screenshots de UI: bug reporting automatizado, extração de dados de sistemas sem API, geração de alt text para acessibilidade, monitoramento visual de dashboards.

Visão para automação: agentes que precisam “ver” a tela para navegar interfaces, robótica com visão por LLM, inspeção de qualidade em manufatura.

Esta unidade te ensina os fundamentos técnicos para todos esses casos.

Conceitos Fundamentais

Como Modelos de Visão Funcionam

Modelos multimodais modernos (como Claude Opus 4.8) usam uma arquitetura que processa texto e imagem em conjunto:

Fundamento: Imagem como Tokens Visuais

Modelos multimodais dividem a imagem em blocos de pixels (patches de 16×16 ou 32×32). Cada patch é projetado para o mesmo espaço vetorial dos embeddings de texto — a imagem vira uma sequência de "tokens visuais". O modelo trata texto e imagem como tokens no mesmo espaço, o que permite atenção cruzada entre eles. O custo segue a fórmula tokens = (largura × altura) / 750: uma imagem de 1200×900 pixels custa ~1.440 tokens. Redimensionar antes de enviar é FinOps real — reduzir para 800×600 corta o custo pela metade sem perda de qualidade perceptível para a maioria das tarefas.

Encoder visual: a imagem é dividida em patches (ex: 16×16 pixels) e cada patch é convertido em um vetor (embedding visual)

Projeção: embeddings visuais são projetados para o mesmo espaço de dimensão que embeddings de texto

Atenção cruzada: o transformer processa tokens de texto e embeddings de imagem juntos, permitindo que cada token de texto “atenda” a partes específicas da imagem

O resultado: o modelo vê a imagem não como pixels mas como vetores no mesmo espaço semântico que o texto. Quando você pergunta “qual o número da NF-e nesta imagem?”, o modelo pode localizar visualmente onde está o número e extraí-lo.

Formatos de Input de Imagem

A API da Anthropic suporta duas formas de enviar imagens:

Por URL pública:

{

    'type': 'image',

    'source': {

        'type': 'url',

        'url': 'https://exemplo.com/imagem.png'

    }

}

Simples de usar — apenas a URL

A Anthropic busca a imagem em tempo real (adiciona latência de rede)

A URL precisa ser acessível publicamente

Não funciona para imagens locais ou privadas

Por base64:

import base64



with open('documento.pdf.png', 'rb') as f:

    data = base64.standard_b64encode(f.read()).decode('utf-8')



{

    'type': 'image',

    'source': {

        'type': 'base64',

        'media_type': 'image/png',  # image/jpeg, image/gif, image/webp

        'data': data

    }

}

Funciona para imagens locais e privadas

Toda a imagem vai no request body (maior payload)

Sem dependência de URL acessível externamente

Melhor para documentos sensíveis (não saem da sua infraestrutura antes de chegar à Anthropic)

Limites e Considerações de Custo

Imagens consomem tokens. A API da Anthropic calcula tokens de imagem assim:

Para imagens menores que 1568px em qualquer dimensão:

tokens = (width × height) / 750



Para imagens maiores (max: 8000×8000px redimensionado):

tokens = após redimensionamento para caber em 8000×8000

Uma foto 1024×768 ≈ 1048 tokens. Com Claude Sonnet ($3/1M tokens input), cada análise de imagem custa ~$0.003. Parece pouco, mas com volume alto (10.000 análises/dia = $30/dia = $900/mês) o custo é relevante.

Otimizações de custo para imagens: - Redimensione imagens grandes antes de enviar (800px de largura é suficiente para a maioria dos casos de OCR) - Use Claude Haiku para análises simples (classificação, OCR básico) - Use Sonnet/Opus apenas quando precisar de raciocínio visual complexo

Prompt Engineering para Visão

Prompts para análise de imagem seguem as mesmas regras de prompts de texto, mas com algumas diferenças:

Seja específico sobre o que quer:

# VAGO — resultado inconsistente

'Analise esta imagem.'



# ESPECÍFICO — resultado previsível

'Esta imagem contém uma nota fiscal. Extraia: número da NF-e, data de emissão, CNPJ do emitente, valor total. Se algum campo não estiver visível, use null.'

Instrua o formato de output:

# Para extração estruturada, sempre especifique o JSON esperado

prompt = """Analise esta imagem de nota fiscal. Retorne EXATAMENTE este JSON:

{

  "numero_nfe": "string ou null",

  "data_emissao": "DD/MM/AAAA ou null",

  "cnpj_emitente": "string sem formatação ou null",

  "valor_total": número_float ou null,

  "confianca": "alta|media|baixa"

}

Se a imagem não for uma nota fiscal, retorne {"erro": "nao_documento"}."""

Instrua sobre incerteza:

# Bom: instrui o modelo a ser honesto sobre o que não consegue ler

'Se algum campo estiver ilegível ou parcialmente visível, use confianca: "baixa" e indique o campo no campo "observacoes".'

Aprofundamento Técnico

Estrutura Completa de um Request Multimodal

import anthropic

import base64



client = anthropic.Anthropic()



# Request com imagem URL + texto

response = client.messages.create(

    model='claude-opus-4-8',  # Opus tem melhor capacidade visual

    max_tokens=500,

    messages=[{

        'role': 'user',

        'content': [

            # PRIMEIRO a imagem (contexto visual)

            {

                'type': 'image',

                'source': {

                    'type': 'url',

                    'url': 'https://example.com/nota-fiscal.png',

                }

            },

            # DEPOIS o texto (pergunta/instrução)

            {

                'type': 'text',

                'text': 'Extraia os dados desta nota fiscal em JSON.'

            }

        ]

    }]

)



# Extração do resultado

texto = response.content[0].text

tokens_usados = response.usage.input_tokens  # inclui tokens da imagem

Ordem na lista content: por convenção, coloque a imagem antes do texto. O modelo processa melhor quando vê a imagem primeiro e depois a instrução relacionada a ela.

Múltiplas imagens: você pode enviar até 5 imagens por request (limite atual):

content = [

    {'type': 'image', 'source': {'type': 'url', 'url': url1}},

    {'type': 'image', 'source': {'type': 'url', 'url': url2}},

    {'type': 'text', 'text': 'Compare estas duas imagens e liste as diferenças.'}

]

Extração Robusta de JSON de Responses de Visão

Modelos de visão tendem a adicionar mais texto ao redor do JSON do que modelos puros de texto. A função de extração robusta que você implementou na Unidade 03 é essencial aqui:

import json

import re



def extrair_json_de_visao(texto: str) -> dict:

    """

    Versão reforçada para responses de visão que frequentemente têm

    texto explicativo antes/depois do JSON.

    """

    texto = texto.strip()



    # Estratégia 1: JSON puro

    try:

        return json.loads(texto)

    except json.JSONDecodeError:

        pass



    # Estratégia 2: ```json ... ```

    match = re.search(r'```(?:json)?\s*\n?([\s\S]+?)\n?```', texto, re.DOTALL)

    if match:

        try:

            return json.loads(match.group(1).strip())

        except json.JSONDecodeError:

            pass



    # Estratégia 3: Primeiro { ... } completo

    # findall para pegar todos os objetos JSON candidatos

    candidatos = re.findall(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', texto)

    for candidato in candidatos:

        try:

            return json.loads(candidato)

        except json.JSONDecodeError:

            continue



    # Estratégia 4: Último recurso — procura por qualquer { ... }

    match = re.search(r'\{[\s\S]+\}', texto, re.DOTALL)

    if match:

        try:

            return json.loads(match.group(0))

        except json.JSONDecodeError:

            pass



    return {'erro': 'Não foi possível extrair JSON', 'resposta_raw': texto[:300]}

Casos de Uso Práticos com Código

OCR de documentos estruturados:

def extrair_dados_nfe(image_url_ou_base64: str, tipo: str = 'url') -> dict:

    """Extrai dados de uma Nota Fiscal Eletrônica via Vision."""



    if tipo == 'url':

        source = {'type': 'url', 'url': image_url_ou_base64}

    else:

        source = {'type': 'base64', 'media_type': 'image/png', 'data': image_url_ou_base64}



    response = client.messages.create(

        model='claude-opus-4-8',

        max_tokens=800,

        messages=[{

            'role': 'user',

            'content': [

                {'type': 'image', 'source': source},

                {'type': 'text', 'text': """Analise esta Nota Fiscal Eletrônica brasileira.

Extraia os dados e retorne JSON no formato:

{

  "numero_nfe": "string",

  "serie": "string",

  "data_emissao": "DD/MM/AAAA",

  "hora_emissao": "HH:MM:SS",

  "cnpj_emitente": "apenas dígitos",

  "nome_emitente": "string",

  "valor_total": número,

  "chave_acesso": "44 dígitos sem espaços",

  "confianca": "alta|media|baixa",

  "campos_ilegíveis": []

}

Se algum campo não estiver visível, use null."""}

            ]

        }]

    )



    return extrair_json_de_visao(response.content[0].text)





**Análise de screenshots de UI:**

```python

def gerar_alt_text(image_base64: str, media_type: str = 'image/png') -> str:

    """Gera alt text descritivo para uma imagem de UI (acessibilidade)."""

    response = client.messages.create(

        model='claude-sonnet-4-6',  # Sonnet é suficiente para alt text

        max_tokens=200,

        messages=[{

            'role': 'user',

            'content': [

                {'type': 'image', 'source': {'type': 'base64', 'media_type': media_type, 'data': image_base64}},

                {'type': 'text', 'text': """Gere alt text para esta imagem de interface de usuário.

Requisitos:

- 1-2 frases descritivas

- Descreva o que está na imagem, não como parece

- Para botões/links, descreva a ação que realizam

- Inclua texto visível na imagem

- Seja específico e útil para usuários com deficiência visual"""}

            ]

        }]

    )

    return response.content[0].text.strip()





def descrever_diagrama(image_url: str) -> dict:

    """Analisa um diagrama técnico e extrai componentes e relações."""

    response = client.messages.create(

        model='claude-opus-4-8',

        max_tokens=600,

        messages=[{

            'role': 'user',

            'content': [

                {'type': 'image', 'source': {'type': 'url', 'url': image_url}},

                {'type': 'text', 'text': """Analise este diagrama técnico. Retorne JSON:

{

  "tipo_diagrama": "arquitetura|fluxo|ER|sequencia|componentes|outro",

  "componentes": [{"nome": "...", "tipo": "..."}],

  "conexoes": [{"de": "...", "para": "...", "tipo": "..."}],

  "descricao": "resumo em 2-3 frases"

}"""}

            ]

        }]

    )

    return extrair_json_de_visao(response.content[0].text)

Exemplos Anotados

Exemplo 1: Pipeline Completo de Extração de Documentos

import anthropic

import base64

import json

import re

from pathlib import Path



client = anthropic.Anthropic()





def imagem_para_base64(caminho: str) -> tuple[str, str]:

    """Converte arquivo de imagem para base64 com detecção de media type."""

    path = Path(caminho)

    media_types = {

        '.jpg': 'image/jpeg',

        '.jpeg': 'image/jpeg',

        '.png': 'image/png',

        '.gif': 'image/gif',

        '.webp': 'image/webp',

    }

    media_type = media_types.get(path.suffix.lower(), 'image/jpeg')



    with open(path, 'rb') as f:

        data = base64.standard_b64encode(f.read()).decode('utf-8')



    return data, media_type





def extrair_dados_estruturados(

    source: dict,

    tipo_documento: str = 'genérico'

) -> dict:

    """

    Extrai dados estruturados de qualquer imagem de documento.



    source: {'type': 'url', 'url': '...'} ou

            {'type': 'base64', 'media_type': '...', 'data': '...'}

    """



    # TODO 1: Construir prompt que pede dados estruturados

    # O prompt deve:

    # 1. Descrever o tipo de documento esperado

    # 2. Especificar o formato JSON de retorno

    # 3. Instruir sobre campos ilegíveis (usar null + confianca: baixa)

    prompt = f"""Analise esta imagem de documento ({tipo_documento}).

Extraia todos os dados visíveis e retorne JSON com:

{{

  "tipo_documento": "recibo|nota_fiscal|contrato|formulario|foto|outro",

  "dados": {{

    "titulo": "string ou null",

    "data": "string ou null",

    "valor": número ou null,

    "partes_envolvidas": ["nome1", "nome2"],

    "outros_campos": {{}}

  }},

  "confianca": "alta|media|baixa",

  "observacoes": "campos ilegíveis ou observações importantes"

}}



Se não conseguir identificar um documento, retorne:

{{"tipo_documento": "nao_identificado", "dados": {{}}, "confianca": "baixa"}}"""



    response = client.messages.create(

        model='claude-opus-4-8',

        max_tokens=800,

        messages=[{

            'role': 'user',

            'content': [

                {'type': 'image', 'source': source},

                {'type': 'text', 'text': prompt}

            ]

        }]

    )



    texto = response.content[0].text



    # TODO 2: Extrair JSON da resposta de texto

    # Use json.loads() ou parse robusto com regex como fallback

    try:

        return json.loads(texto)

    except json.JSONDecodeError:

        # Tenta encontrar { ... } no texto

        match = re.search(r'\{[\s\S]+\}', texto)

        if match:

            try:

                return json.loads(match.group(0))

            except json.JSONDecodeError:

                pass

        return {'erro': 'JSON inválido', 'texto_original': texto[:300]}





def analisar_url(url: str) -> dict:

    """Conveniência: analisa imagem por URL."""

    return extrair_dados_estruturados(

        source={'type': 'url', 'url': url},

        tipo_documento='desconhecido'

    )





def analisar_arquivo_local(caminho: str) -> dict:

    """Conveniência: analisa imagem de arquivo local."""

    data, media_type = imagem_para_base64(caminho)

    return extrair_dados_estruturados(

        source={'type': 'base64', 'media_type': media_type, 'data': data},

        tipo_documento='documento local'

    )





# Demonstração com imagem pública (diagrama de arquitetura 3-tier)

url_teste = 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/3f/Three_tier_architecture.png/300px-Three_tier_architecture.png'



print("Analisando diagrama de arquitetura...")

resultado = analisar_url(url_teste)

print(f"Tipo: {resultado.get('tipo_documento')}")

print(f"Dados: {json.dumps(resultado.get('dados', {}), ensure_ascii=False, indent=2)}")

print(f"Confiança: {resultado.get('confianca')}")

Exemplo 2: Análise com Múltiplas Tentativas

def analisar_com_fallback(url: str, tentativas: int = 2) -> dict:

    """

    Tenta extrair dados estruturados de uma imagem.

    Se a primeira resposta não for JSON válido, tenta de novo com

    instrução mais explícita de formatação.

    """

    historico_content = [

        {'type': 'image', 'source': {'type': 'url', 'url': url}},

    ]



    prompts = [

        # Primeira tentativa: prompt normal

        'Extraia os dados desta imagem em JSON. Campos: tipo, dados, confianca.',

        # Segunda tentativa: mais explícito se a primeira falhar

        'Retorne APENAS JSON válido, sem texto antes ou depois. Estrutura: {"tipo": "...", "dados": {}, "confianca": "alta|media|baixa"}',

    ]



    for i, prompt in enumerate(prompts[:tentativas]):

        mensagens_content = historico_content + [{'type': 'text', 'text': prompt}]



        response = client.messages.create(

            model='claude-opus-4-8',

            max_tokens=600,

            messages=[{'role': 'user', 'content': mensagens_content}]

        )



        texto = response.content[0].text



        try:

            dados = json.loads(texto.strip())

            if isinstance(dados, dict) and 'tipo' in dados:

                return dados  # sucesso

        except json.JSONDecodeError:

            # Tenta extração com regex

            match = re.search(r'\{[\s\S]+\}', texto)

            if match:

                try:

                    return json.loads(match.group(0))

                except:

                    pass



        print(f"  Tentativa {i+1} falhou — resposta: {texto[:100]}")



    return {'erro': f'Falhou após {tentativas} tentativas', 'tipo': 'erro'}

Padrões e Armadilhas

Padrões

Padrão 1: Especifique o JSON esperado no prompt, não apenas “retorne JSON”

# VAGO — o modelo inventa a estrutura

'Extraia os dados em JSON.'



# ESPECÍFICO — o modelo segue a estrutura

'Retorne EXATAMENTE este JSON: {"campo1": "...", "campo2": número}'

A estrutura explícita garante consistência entre análises de documentos diferentes.

Padrão 2: Use Claude Haiku para pré-triagem, Opus para extração detalhada

# Triagem: é um documento ou foto aleatória?

tipo = client_haiku.messages.create(

    max_tokens=50,

    messages=[..., 'Responda apenas: DOCUMENTO ou NAO_DOCUMENTO']

).content[0].text



if 'DOCUMENTO' in tipo:

    # Extração detalhada só para documentos

    dados = client_opus.messages.create(max_tokens=800, ...).content[0].text

Padrão 3: Instrua sobre confiança e campos ilegíveis Sempre inclua no prompt: “Se algum campo não estiver visível, use null. Inclua ‘confianca’: ‘baixa’ se houver partes ilegíveis.” Isso evita que o modelo invente dados para campos que não consegue ler.

Armadilhas

⚠️ Armadilha 1: Não tratar imagens muito grandes

# Imagem 4000×3000 ≈ 16.000 tokens = $0.048 por análise com Opus

# 10.000 análises = $480 APENAS em tokens de imagem



# SOLUÇÃO: redimensione antes de enviar

from PIL import Image



def redimensionar_para_api(caminho: str, max_lado: int = 1000) -> str:

    """Redimensiona mantendo proporção se necessário."""

    img = Image.open(caminho)

    if max(img.size) > max_lado:

        img.thumbnail((max_lado, max_lado))

        # salva temporariamente e converte para base64

⚠️ Armadilha 2: URL que não é publicamente acessível

# ERRADO: URL de storage privado (S3 sem pre-signed URL)

url = 'https://s3.amazonaws.com/meu-bucket-privado/documento.png'

# → A API tenta buscar e falha com 403



# CORRETO: use base64 para imagens privadas, ou gere pre-signed URL

⚠️ Armadilha 3: max_tokens muito baixo para extração de documentos

# Um documento com 10+ campos pode precisar de 300-500 tokens para o JSON

max_tokens=100  # JSON truncado no meio → JSONDecodeError



# Dê margem generosa — output de documentos é barato (Haiku: $1.25/1M)

max_tokens=800

⚠️ Armadilha 4: Confiar no JSON sem validar campos críticos

# ERRADO: usa diretamente sem checar se o campo existe

cnpj = dados['cnpj_emitente']  # KeyError se campo ausente



# CORRETO: .get() com default

cnpj = dados.get('cnpj_emitente')  # None se ausente, sem exceção

if cnpj is None:

    print("CNPJ não encontrado na imagem")
⚗ Laboratório prático — mozak.tech
Se não for realizar o laboratório, pule para o próximo capítulo.

Ponte para o Lab

O starter tem 2 TODOs:

TODO 1 — Em extrair_dados_estruturados(): construa o prompt completo para extração de dados. O prompt deve: (1) descrever o que você quer extrair (“dados visíveis desta imagem de documento”), (2) especificar o formato JSON de retorno com campos tipo_documento, campos, confianca, e observacoes, (3) instruir sobre campos ilegíveis (“use null e confianca: baixa”). A estrutura do prompt está na seção Conceitos Fundamentais → “Prompt Engineering para Visão”.

TODO 2 — Em extrair_dados_estruturados(), após obter texto = resposta.content[0].text: extraia o JSON do texto. Primeiro tente json.loads(texto). Se falhar com JSONDecodeError, use re.search(r'\{[\s\S]+\}', texto) para encontrar o primeiro objeto JSON no texto e tente novamente. Se ainda falhar, retorne o dict de erro com texto_original. Seção de referência: Aprofundamento Técnico → “Extração Robusta de JSON de Responses de Visão”.

Após implementar, o main() testa com uma imagem pública de diagrama. Como é um diagrama e não um documento, o modelo deve retornar tipo_documento: "outro" com os componentes que consegue identificar visualmente. Experimente também com uma imagem de recibo ou nota fiscal real para ver a extração funcionar melhor.

Agora você está pronto para o lab.