O alizarol é o único teste de qualidade feito hoje na porteira, e é o motorista quem decide, olhando um tubo contra a luz, se o leite entra na carga. A câmara óptica do LactiGuard fotografa o mesmo tubo em condições controladas e devolve dois números, um índice de grumos e um tom de cor, com as fotos guardadas como prova. Esta página explica como, incluindo o código.
Luz padronizada, câmera com parâmetros travados, um cartão de referência de cor no quadro e dois algoritmos simples: um mede a cor do líquido no espaço CIELAB, o outro conta partículas no filme que escorre na parede do vidro.
O reagente é álcool a 72% (v/v, pela IN 76) com alizarina, um indicador de pH. O álcool provoca coagulação se a proteína estiver instável; a alizarina muda de cor conforme o pH. O olho humano mistura as duas informações numa leitura só; a câmera as separa.
Vem da alizarina e diz se o leite está ácido, normal ou alcalino. É lida como um ângulo de tom (h) no espaço de cor CIELAB, depois de corrigir o balanço de branco pelo cartão de referência.
Vêm do álcool e dizem se a proteína está instável. São lidos como um índice de 0 a 100 a partir da textura e da contagem de partículas na foto do tubo inclinado, onde o filme escorre pela parede e os grumos ficam evidentes.
| Cor (tom) | Grumos | pH da sonda ESP32 | Classe | O que significa |
|---|---|---|---|---|
| Normal (rosado) | ausentes | 6,6–6,8 | Estável | Leite dentro do padrão da IN 76 para o alizarol 72%. |
| Normal (rosado) | presentes | 6,6–6,8 | LINA | Leite instável não ácido: proteína instável por desequilíbrio mineral ou nutricional; não é acidez. |
| Amarelo/pardo | presentes | < 6,5 | Ácido | Fermentação por bactérias: refrigeração ou higiene falhas. Barrar. |
| Violeta/lilás | ausentes | > 6,9 | Alcalino | Mastite avançada ou neutralizante adicionado; cruzar com condutividade, CCS e kit de neutralizantes. |
É este cruzamento que o olho humano não faz de forma confiável: cor normal com grumos é LINA, não acidez; grumos com pH baixo é acidez; violeta sem grumos com pH alto sugere neutralizante.
| Item | Especificação | Custo aproximado |
|---|---|---|
| Câmera | Raspberry Pi Camera Module 3, 12 MP, autofoco travado em distância fixa | R$ 250–350 |
| Painel de luz de fundo | Painel LED branco 10×10 cm + difusor de acrílico opalino 3 mm | R$ 60 |
| Luz frontal | Fita LED CRI ≥ 90, 5000 K, 12 V, com difusor | R$ 40 |
| Caixa | Impressa em 3D (PETG preto fosco) ou MDF pintado, vedada à luz, com sensor de tampa | R$ 80 |
| Berço e servo | Berço impresso + servo SG90 + driver no hub | R$ 25 |
| Referências | Cartão cinza 18% + ColorChecker mini (ou impressão calibrada) + marcadores ArUco | R$ 80–400 |
| Dosagem | 2 pipetas de volume fixo de 2 mL | R$ 60 |
| Total | sem o hub Raspberry Pi, que já faz parte do kit | < R$ 700 |
O resultado visual depende do gesto. Por isso o app guia o motorista nestes quatro passos e só libera a foto quando o tempo fixo é atingido.
Tudo roda no próprio Raspberry Pi, em Python com picamera2, OpenCV e NumPy, em cerca de dois segundos por amostra. O resultado vai para o motor de regras e para o comprovante.
Foto em formato sem perdas (PNG ou RAW) com os parâmetros travados. O software localiza os marcadores ArUco e recorta três regiões: o tubo, o cartão cinza e o cartão de cores. Nada depende de posição manual.
Calcula o ganho de cada canal (R, G, B) que torna o cartão cinza neutro e aplica ao quadro inteiro. Uma vez por semana, com o cartão de cores completo, recalcula uma matriz de correção 3×3 que compensa a deriva do LED.
Converte a coluna central do líquido (sem o menisco e sem o fundo) de sRGB para CIELAB e mede a média de L*, a*, b*. O tom é o ângulo h = atan2(b*, a*). Pontos de partida: amarelo-pardo 70–100°, rosado a vermelho-tijolo 20–50°, violeta acima de 300°. Os limites reais saem da calibração, porque a gordura espalha luz e o lote de alizarol varia.
Na foto inclinada com luz frontal (e também na de transmissão): converte para cinza; mede textura pela variância do Laplaciano ou pelo desvio padrão local em janela de 15 px; aplica limiar adaptativo e conta componentes conexos maiores que o equivalente a 0,1 mm², somando a área total; mede a densidade de bordas (Canny). Leite estável dá filme liso; instável dá dezenas de partículas.
Instável se o índice passar do limiar calibrado, com uma faixa de incerteza de ±10 em que o app pede confirmação e usa o pH e a acidez estimada da sonda como desempate. A classe de cor é cruzada com o pH: grumos com pH baixo é acidez; grumos com pH normal é LINA; violeta sem grumos com pH alto sugere neutralizante.
As duas fotos, os números, os parâmetros da câmera, o lote do reagente e o hash da coleta entram no comprovante do produtor e no registro do laticínio. É a prova que o alizarol a olho nunca deu.
# captura com parâmetros travados (a regra mais importante) cam.set_controls({"AwbEnable": False, "AeEnable": False, "ExposureTime": 8000, "AnalogueGain": 1.0, "ColourGains": (g_r, g_b)}) # g_r, g_b vêm da calibração luz.transmissao() # painel atrás do tubo img = cam.capture_array() # foto 1: tubo em pé tubo, cinza, cartao = recortar_por_aruco(img) # regiões fixas pelo berço img = corrigir_branco(img, cinza) # ganho por canal até o cinza ficar neutro # dimensão 1: tom do líquido em CIELAB lab = cv2.cvtColor(tubo_liquido, cv2.COLOR_RGB2LAB) # coluna central, sem menisco e sem fundo L, a, b = lab.reshape(-1, 3).mean(axis=0) tom = np.degrees(np.arctan2(b - 128, a - 128)) % 360 # ângulo de tom (OpenCV centra a*, b* em 128) classe_cor = classificar_tom(tom) # acido / normal / alcalino, limites calibrados # dimensão 2: grumos no filme da parede luz.frontal(); servo.inclinar(45); time.sleep(1.5) # foto 2: filme escorrendo na parede img2 = cam.capture_array(); parede = recortar_parede(img2) g = cv2.cvtColor(parede, cv2.COLOR_RGB2GRAY) textura = cv2.Laplacian(g, cv2.CV_64F).var() mask = cv2.adaptiveThreshold(g, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 8) n, _, stats, _ = cv2.connectedComponentsWithStats(mask) areas = stats[1:, cv2.CC_STAT_AREA] area_grumos = areas[areas > AREA_MIN].sum() # AREA_MIN ≈ 0,1 mm² em pixels bordas = cv2.Canny(g, 60, 140).mean() indice = escalar(w1*textura + w2*area_grumos/parede.size + w3*bordas) # 0–100, pesos da calibração servo.inclinar(0) # decisão cruzada com a sonda de pH do nó ESP32 instavel = indice > LIMIAR # faixa de incerteza: LIMIAR ± 10 pede confirmação classe = classificar(instavel, classe_cor, ph_sonda) # estavel / lina / acido / alcalino registrar(img, img2, dict(tom=tom, indice=indice, classe=classe, exposicao=8000, ganhos=(g_r, g_b), lote=lote_alizarol))
As funções recortar_por_aruco, corrigir_branco, classificar_tom e escalar são utilitários curtos (cv2.aruco, aritmética de ganhos, limiares e normalização). Os pesos w1–w3, LIMIAR e os limites de tom são os parâmetros que saem da calibração abaixo e ficam num arquivo de configuração versionado por lote de reagente.
Três escadas de amostras, dez réplicas por ponto, três leitores humanos e o laboratório como referência. É assim que o índice deixa de ser uma opinião da câmera.
Com as escadas se ajustam os pesos do índice, o limiar de "instável", os limites de tom contra o pH medido, e se calculam sensibilidade, especificidade, concordância com os leitores (kappa) e repetibilidade. Depois de algumas centenas de amostras de rota com laudo pareado, dá para trocar os pesos fixos por um classificador treinado nas mesmas características; isso é fase 3 do projeto, não fase 1.