Introdução ao Data Science com Python

Introdução ao Data Science com Python

NumPy, pandas, Matplotlib, Seaborn e SciPy na prática: arrays, DataFrames, limpeza, gráficos e testes estatísticos — e os erros que não levantam exceção, como fillna com inplace que não altera nada no pandas 3, desvio padrão com ddof diferente e p-valor alto lido como prova.
Python

• • 16 min de leitura

Data Science é a disciplina que extrai conhecimento e insights de dados — combinando estatística, programação e domínio do problema. Python se tornou a linguagem dominante nessa área por um motivo simples: seu ecossistema de bibliotecas é incomparável. NumPy, pandas, Matplotlib e Seaborn formam a base sobre a qual toda análise de dados em Python é construída. Neste artigo apresentamos esse ecossistema de forma sistemática e prática.

O Ecossistema de Data Science

Dados brutos
     │
     ▼
┌─────────────┐   coleta e limpeza    ┌──────────────┐
│   pandas    │ ◄──────────────────── │  requests /  │
│  (tabelas)  │                       │  scrapy / sql│
└──────┬──────┘                       └──────────────┘
       │ arrays numéricos
       ▼
┌─────────────┐   visualização        ┌──────────────┐
│    NumPy    │ ────────────────────► │  Matplotlib  │
│  (vetores)  │                       │   Seaborn    │
└──────┬──────┘                       └──────────────┘
       │ modelagem
       ▼
┌─────────────┐   avaliação           ┌──────────────┐
│ scikit-learn│ ────────────────────► │   métricas / │
│    (ML)     │                       │   relatórios │
└─────────────┘                       └──────────────┘
pip install numpy pandas matplotlib seaborn scipy jupyter

NumPy: Computação Numérica

NumPy é a fundação de todo o ecossistema científico Python — arrays multidimensionais com operações vetorizadas, muito mais rápidas que listas Python puras.

import numpy as np

# Criando arrays
a = np.array([1, 2, 3, 4, 5])
b = np.array([10, 20, 30, 40, 50])

print(a.dtype)    # int64
print(a.shape)    # (5,)
print(a.ndim)     # 1

# Operações vetorizadas — sem loop explícito
print(a + b)      # [11 22 33 44 55]
print(a * 2)      # [ 2  4  6  8 10]
print(a ** 2)     # [ 1  4  9 16 25]
print(np.sqrt(a)) # [1.         1.41421356 1.73205081 2.         2.23606798]

# Comparações vetorizadas
print(a > 3)      # [False False False  True  True]
print(a[a > 3])   # [4 5] — indexação booleana

Arrays Multidimensionais

# Matriz 3x4
matriz = np.array([
    [1,  2,  3,  4],
    [5,  6,  7,  8],
    [9, 10, 11, 12]
])

print(matriz.shape)      # (3, 4)
print(matriz[1, 2])      # 7 — linha 1, coluna 2
print(matriz[:, 0])      # [1 5 9] — toda a coluna 0
print(matriz[0, :])      # [1 2 3 4] — toda a linha 0
print(matriz[1:, 2:])    # submatriz

# Criando arrays especiais
zeros = np.zeros((3, 4))
uns   = np.ones((2, 5))
ident = np.eye(3)            # matriz identidade
seq   = np.arange(0, 20, 2)  # [0 2 4 ... 18]
lin   = np.linspace(0, 1, 5) # [0.   0.25  0.5   0.75  1.]
rand  = np.random.rand(3, 3) # valores aleatórios 0-1

Álgebra Linear e Estatística

a = np.array([2, 4, 4, 4, 5, 5, 7, 9])

print(f"Média:     {np.mean(a):.2f}")      # 5.00
print(f"Mediana:   {np.median(a):.2f}")    # 4.50
print(f"Desvio:    {np.std(a):.2f}")       # 2.00 — populacional (ddof=0)
print(f"Variância: {np.var(a):.2f}")       # 4.00
print(f"Mínimo:    {np.min(a)}")           # 2
print(f"Máximo:    {np.max(a)}")           # 9
print(f"Soma:      {np.sum(a)}")           # 40
print(f"Percentil 75: {np.percentile(a, 75)}")  # 5.5

# Desvio amostral (ddof=1), o padrão do .std() do pandas
print(np.std(a, ddof=1))    # 2.138...

# Produto matricial
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
print(A @ B)       # produto matricial
print(np.dot(A, B))

# Autovalores e autovetores
vals, vecs = np.linalg.eig(A)
print(vals)   # [-0.37228132+0.j  5.37228132+0.j] — o NumPy 2.5 devolve complexos

# Resolução de sistema linear Ax = b
A = np.array([[2, 1], [1, 3]], dtype=float)
b = np.array([5, 10], dtype=float)
x = np.linalg.solve(A, b)
print(x)   # [1. 3.] — confere: 2·1 + 3 = 5 e 1 + 3·3 = 10

pandas: Análise de Dados Tabulares

O pandas organiza dados em tabelas — o DataFrame, com colunas nomeadas e tipadas — e faz em uma linha de código o que numa planilha pede fórmula arrastada ou tabela dinâmica.

import pandas as pd

# Criando DataFrame
df = pd.DataFrame({
    "nome":       ["Ana", "Bruno", "Carla", "Diego", "Elena"],
    "idade":      [23, 35, 28, 42, 31],
    "cidade":     ["Recife", "SP", "RJ", "SP", "Recife"],
    "salario":    [4500.0, 8200.0, 6300.0, 12000.0, 5800.0],
    "ativo":      [True, True, False, True, True]
})

# Informações básicas
print(df.shape)          # (5, 5)
print(df.dtypes)         # tipos de cada coluna
print(df.describe())     # estatísticas descritivas
df.info()                # visão geral + memória (imprime sozinho e devolve None)
print(df.head(3))        # primeiras 3 linhas
print(df.tail(2))        # últimas 2 linhas

Seleção e Filtragem

# Selecionando colunas
print(df["nome"])                    # Series
print(df[["nome", "salario"]])       # DataFrame

# loc — por rótulo
print(df.loc[0])                     # linha 0
print(df.loc[1:3, ["nome", "idade"]]) # linhas 1 a 3 — no loc, o fim entra

# iloc — por posição inteira
print(df.iloc[0, 1])                 # linha 0, coluna 1
print(df.iloc[:3, :2])              # primeiras 3 linhas, 2 colunas

# Filtrando
ativos    = df[df["ativo"] == True]
sp        = df[df["cidade"] == "SP"]
alto_sal  = df[df["salario"] > 6000]
sp_ativo  = df[(df["cidade"] == "SP") & (df["ativo"] == True)]
recife_ou_rj = df[df["cidade"].isin(["Recife", "RJ"])]

# query() — sintaxe SQL-like
resultado = df.query("salario > 5000 and ativo == True")

Transformações e Cálculos

# Novas colunas
df["bonus"]        = df["salario"] * 0.1
df["faixa_etaria"] = pd.cut(
    df["idade"],
    bins=[0, 25, 35, 50, 100],
    labels=["Jovem", "Adulto", "Meia-idade", "Sênior"]
)

# Aplicando funções
df["nome_upper"] = df["nome"].str.upper()
df["salario_k"]  = df["salario"].apply(lambda x: f"R$ {x/1000:.1f}k")

# Operações sobre grupos
por_cidade = df.groupby("cidade").agg(
    total_funcionarios=("nome",    "count"),
    salario_medio=     ("salario", "mean"),
    salario_total=     ("salario", "sum"),
    idade_media=       ("idade",   "mean")
).round(2)

print(por_cidade)

# Pivot table
pivot = df.pivot_table(
    values="salario",
    index="cidade",
    columns="ativo",
    aggfunc="mean"
)
print(pivot)

Limpeza de Dados

import numpy as np

# Dataset com problemas
df_sujo = pd.DataFrame({
    "nome":    ["Ana", "Bruno", None,  "Diego", "Ana",    "Elena"],
    "idade":   [23,    35,      28,    None,    23,       -5],
    "salario": [4500,  8200,    6300,  12000,   4500,     np.nan],
    "email":   ["ana@email.com", "bruno@email.com", "invalido",
                "diego@email.com", "ana@email.com", "elena@email.com"]
})

print("=== Dados sujos ===")
print(df_sujo)
print(f"\nValores nulos:\n{df_sujo.isnull().sum()}")
print(f"\nDuplicatas: {df_sujo.duplicated().sum()}")

# 1. Removendo duplicatas
df_limpo = df_sujo.drop_duplicates()

# 2. Tratando valores nulos — sempre atribuindo o resultado.
#    No pandas 3, df["col"].fillna(..., inplace=True) não altera o df.
df_limpo = df_limpo.fillna({
    "nome":    "Desconhecido",
    "salario": df_limpo["salario"].median(),
})
df_limpo = df_limpo.dropna(subset=["idade"])  # remove linhas sem idade

# 3. Corrigindo valores inválidos
df_limpo = df_limpo[df_limpo["idade"] > 0]

# 4. Validando e-mails
df_limpo["email_valido"] = df_limpo["email"].str.fullmatch(
    r"[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}"
)

# 5. Normalizando tipos
df_limpo["idade"]   = df_limpo["idade"].astype(int)
df_limpo["salario"] = df_limpo["salario"].astype(float)

print("\n=== Dados limpos ===")
print(df_limpo)

Matplotlib: Visualização Fundamental

import matplotlib.pyplot as plt
import numpy as np

# Configuração global
plt.rcParams.update({
    "figure.figsize":  (10, 6),
    "axes.spines.top":    False,
    "axes.spines.right":  False,
    "font.family":     "DejaVu Sans"
})


def graficos_basicos():
    fig, axes = plt.subplots(2, 3, figsize=(15, 10))
    fig.suptitle("Tipos de Gráficos", fontsize=16, fontweight="bold")

    # 1. Linha
    x  = np.linspace(0, 10, 100)
    ax = axes[0, 0]
    ax.plot(x, np.sin(x), label="sen(x)", color="#2E75B6")
    ax.plot(x, np.cos(x), label="cos(x)", color="#C00000", linestyle="--")
    ax.set_title("Gráfico de Linha")
    ax.legend()
    ax.grid(alpha=0.3)

    # 2. Barras
    categorias = ["Jan", "Fev", "Mar", "Abr", "Mai"]
    valores    = [42, 58, 71, 65, 83]
    ax = axes[0, 1]
    bars = ax.bar(categorias, valores, color="#2E75B6", alpha=0.8)
    ax.bar_label(bars, padding=2)
    ax.set_title("Gráfico de Barras")

    # 3. Dispersão
    np.random.seed(42)
    x = np.random.randn(100)
    y = 2 * x + np.random.randn(100)
    ax = axes[0, 2]
    ax.scatter(x, y, alpha=0.6, color="#2E75B6")
    ax.set_title("Dispersão")

    # 4. Histograma
    dados = np.random.normal(170, 10, 500)
    ax = axes[1, 0]
    ax.hist(dados, bins=30, color="#2E75B6", alpha=0.8, edgecolor="white")
    ax.set_title("Histograma")
    ax.set_xlabel("Altura (cm)")

    # 5. Pizza
    labels = ["Aprovados", "Reprovados", "Recuperação"]
    sizes  = [65, 20, 15]
    cores  = ["#375623", "#C00000", "#F5A623"]
    ax = axes[1, 1]
    ax.pie(sizes, labels=labels, colors=cores, autopct="%1.1f%%", startangle=90)
    ax.set_title("Pizza")

    # 6. Box Plot
    dados_box = [np.random.normal(loc, 1, 100) for loc in [2, 3, 5, 8]]
    ax = axes[1, 2]
    ax.boxplot(dados_box, tick_labels=["A", "B", "C", "D"], patch_artist=True)
    ax.set_title("Box Plot")

    plt.tight_layout()
    plt.savefig("graficos_basicos.png", dpi=150, bbox_inches="tight")
    plt.show()


graficos_basicos()

Seaborn: Visualizações Estatísticas

import numpy as np
import seaborn as sns
import pandas as pd
import matplotlib.pyplot as plt

# Dataset de exemplo: a nota cresce com as horas de estudo, com ruído
np.random.seed(42)
horas = np.random.uniform(1, 8, 200)
df_vis = pd.DataFrame({
    "horas_estudo": horas,
    "nota":         (3.5 + 0.6 * horas + np.random.normal(0, 1.2, 200)).clip(0, 10),
    "turma":        np.random.choice(["A", "B", "C"], 200),
})
df_vis["aprovado"] = (df_vis["nota"] >= 6).map({True: "Sim", False: "Não"})

sns.set_theme(style="whitegrid", palette="muted")
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# 1. Distribuição de notas
sns.histplot(
    data=df_vis, x="nota", hue="aprovado",
    multiple="stack", bins=20, ax=axes[0, 0]
)
axes[0, 0].set_title("Distribuição de Notas")
axes[0, 0].axvline(6, color="red", linestyle="--", label="Mínimo")

# 2. Boxplot por turma
sns.boxplot(
    data=df_vis, x="turma", y="nota",
    hue="turma", palette="Blues", legend=False, ax=axes[0, 1]
)
axes[0, 1].set_title("Notas por Turma")

# 3. Scatter com regressão
sns.regplot(
    data=df_vis,
    x="horas_estudo", y="nota",
    scatter_kws={"alpha": 0.4},
    line_kws={"color": "red"},
    ax=axes[1, 0]
)
axes[1, 0].set_title("Horas de Estudo vs Nota")

# 4. Heatmap de correlação
correlacao = df_vis[["nota", "horas_estudo"]].corr()
sns.heatmap(
    correlacao,
    annot=True, fmt=".2f",
    cmap="coolwarm", vmin=-1, vmax=1,   # correlação vai de -1 a 1
    ax=axes[1, 1]
)
axes[1, 1].set_title("Correlação")

plt.suptitle("Análise de Desempenho Escolar", fontsize=16, fontweight="bold")
plt.tight_layout()
plt.savefig("analise_escolar.png", dpi=150, bbox_inches="tight")
plt.show()

Análise Exploratória Completa

def analise_exploratoria(df: pd.DataFrame, coluna_alvo: str):
    """EDA — Exploratory Data Analysis padronizada."""

    print("=" * 50)
    print("ANÁLISE EXPLORATÓRIA DE DADOS")
    print("=" * 50)

    # 1. Visão geral
    print(f"\n📊 Dimensões: {df.shape[0]} linhas × {df.shape[1]} colunas")
    print(f"\n📋 Tipos de dados:\n{df.dtypes}")
    print(f"\n🔍 Valores nulos:\n{df.isnull().sum()}")
    print(f"\n🔁 Duplicatas: {df.duplicated().sum()}")

    # 2. Estatísticas da variável alvo
    alvo = df[coluna_alvo]
    print(f"\n📈 Estatísticas de '{coluna_alvo}':")
    print(f"  Média:      {alvo.mean():.2f}")
    print(f"  Mediana:    {alvo.median():.2f}")
    print(f"  Desvio:     {alvo.std():.2f}")
    print(f"  Assimetria: {alvo.skew():.2f}")
    print(f"  Curtose:    {alvo.kurtosis():.2f}")

    # 3. Correlações com a variável alvo
    numericas  = df.select_dtypes(include=[np.number])
    correlacoes = numericas.corr()[coluna_alvo].sort_values(ascending=False)
    print(f"\n🔗 Correlações com '{coluna_alvo}':\n{correlacoes}")

    # 4. Distribuição de categorias
    categoricas = df.select_dtypes(include=["object", "string", "category"])  # "string": texto no pandas 3
    for col in categoricas.columns:
        print(f"\n📌 {col}:")
        print(df[col].value_counts().head(5))


analise_exploratoria(df_vis, "nota")

SciPy: Estatística Avançada

from scipy import stats

# Teste t — comparar médias de dois grupos
turma_a = df_vis[df_vis["turma"] == "A"]["nota"]
turma_b = df_vis[df_vis["turma"] == "B"]["nota"]

t_stat, p_valor = stats.ttest_ind(turma_a, turma_b)
print(f"Teste t: t={t_stat:.3f}, p={p_valor:.3f}")
print(f"Diferença significativa: {'Sim' if p_valor < 0.05 else 'Não'}")

# Teste de normalidade
stat, p = stats.shapiro(turma_a)
print(f"\nShapiro-Wilk: stat={stat:.3f}, p={p:.3f}")
# p alto não prova normalidade: só diz que o teste não achou evidência contra
print(f"Normalidade rejeitada: {'Sim' if p < 0.05 else 'Não'}")

# Intervalo de confiança
media    = turma_a.mean()
ic       = stats.t.interval(
    confidence=0.95,
    df=len(turma_a) - 1,
    loc=media,
    scale=stats.sem(turma_a)
)
print(f"\nIC 95%: [{ic[0]:.2f}, {ic[1]:.2f}]")

# Regressão linear
slope, intercept, r, p, se = stats.linregress(
    df_vis["horas_estudo"], df_vis["nota"]
)
print(f"\nRegressão Linear:")
print(f"  Inclinação:  {slope:.3f}")
print(f"  Intercepto: {intercept:.3f}")
print(f"  R²:          {r**2:.3f}")

As quatro bibliotecas deste artigo dividem o trabalho de um jeito estável: o NumPy guarda números em arrays e faz as contas sem laço explícito; o pandas põe esses arrays em tabelas com nome, tipo e índice, e é onde a maior parte do tempo vai — seleção, agrupamento e, sobretudo, limpeza; Matplotlib e Seaborn transformam o resultado em gráfico; e o SciPy responde se uma diferença entre grupos é real ou ruído. A análise exploratória junta tudo num roteiro que se repete antes de qualquer modelo. Quem já leu planilhas com o pandas em Automação de Planilhas e PDFs reconhece o DataFrame; aqui ele passa de meio de transporte a objeto de estudo.

Os erros que mais custam nesta área não levantam exceção. Um fillna(inplace=True) que não altera nada no pandas 3, um desvio padrão calculado com a fórmula populacional num lugar e a amostral no outro, um p-valor alto lido como prova — todos produzem um número plausível e errado. Vale conferir a tabela depois de cada passo de limpeza, dizer qual ddof foi usado e escrever a conclusão de um teste estatístico na forma que ele de fato permite, com os avisos de descontinuação ligados enquanto o código é escrito.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um script de limpeza que rodava havia dois anos começou a entregar relatórios com nomes vazios depois que o servidor atualizou o pandas para a versão 3. O código não mudou: df["nome"].fillna("Desconhecido", inplace=True). Não há exceção, só um aviso no log que ninguém lê. O que aconteceu?

Ver resposta

✓ Resposta: O pandas 3 adotou o Copy-on-Write como único comportamento: df["nome"] devolve um objeto que se comporta como cópia, e o inplace=True preenche essa cópia, que é descartada em seguida. Medido no pandas 3.0.6 com o exemplo do artigo: a linha sem nome saiu da limpeza ainda com NaN, e o único sinal foi um ChainedAssignmentError — que, apesar do nome, é um aviso, não uma exceção — dizendo que o método "never works to update the original DataFrame". Em versões anteriores o mesmo código às vezes funcionava, o que explica os dois anos de silêncio. A correção é atribuir o resultado, e de preferência numa chamada só: df = df.fillna({"nome": "Desconhecido", "salario": df["salario"].median()}), como no artigo corrigido. Duas defesas para o futuro: rodar os scripts com -W error em homologação, para que avisos derrubem o teste, e conferir a contagem de nulos depois da limpeza, não só antes.

Exercício 2

Dois analistas calculam o desvio padrão das mesmas oito notas — 2, 4, 4, 4, 5, 5, 7 e 9. Um usa np.std(notas) e obtém 2,00; o outro usa df["nota"].std() e obtém 2,14. Os dois juram que o próprio código está certo. Quem tem razão, e o que deveria constar no relatório?

Ver resposta

✓ Resposta: Os dois calcularam certo coisas diferentes. O NumPy usa por padrão ddof=0, a fórmula populacional, que divide a soma dos quadrados dos desvios por n: 32 / 8 = 4, raiz 2,00. O pandas usa ddof=1, a fórmula amostral, que divide por n − 1: 32 / 7 ≈ 4,57, raiz 2,138 — medido nas duas bibliotecas, e np.std(notas, ddof=1) dá exatamente o valor do pandas. Se as oito notas são a turma inteira, a populacional descreve a turma; se são uma amostra usada para estimar algo maior, a amostral corrige o viés de medir o espalhamento em torno da média da própria amostra. A diferença encolhe com n grande e é de 7% com n = 8. O relatório deve dizer qual das duas foi usada, e o código deve passar o ddof explicitamente, para que ninguém dependa do padrão da biblioteca da vez.

Exercício 3

Antes de aplicar um teste t, uma analista roda stats.shapiro nas notas de uma turma de 10 alunos, obtém p = 0,40 e escreve no relatório: "as notas seguem distribuição normal, conforme o teste de Shapiro-Wilk". O revisor pede para reescrever a frase. Por quê?

Ver resposta

✓ Resposta: Porque o teste não pode afirmar isso. A hipótese nula do Shapiro-Wilk é a normalidade; p abaixo de 0,05 permite rejeitá-la, mas p alto só diz que os dados não trouxeram evidência suficiente contra ela. Com amostra pequena, o teste quase nunca tem força para rejeitar: sorteando 2.000 amostras de uma distribuição uniforme — plana, nada parecida com a normal —, o Shapiro deu p acima de 0,05 em 91% das amostras de 10 valores, em 63% das de 30 e em menos de 1% das de 100. A frase honesta é "o teste de Shapiro-Wilk não rejeitou a normalidade (p = 0,40)", e com dez alunos isso diz pouco; um histograma ou um gráfico Q-Q informa mais. Foi por isso que o código do artigo passou a imprimir "Normalidade rejeitada: Sim/Não" em vez de "Distribuição normal: Sim".

Exercício 4

Para uma aula sobre regressão, um professor gera dados sintéticos com np.random.normal para a nota e np.random.uniform para as horas de estudo, cada coluna sorteada por conta própria. O gráfico de dispersão sai como uma nuvem sem forma, a reta do regplot fica quase horizontal e um aluno conclui que "estudar não adianta". O que deu errado?

Ver resposta

✓ Resposta: Os dados não tinham relação nenhuma porque foram gerados assim: duas colunas sorteadas de forma independente. Com a versão original do exemplo deste artigo, a correlação medida entre horas e nota foi −0,03, a inclinação −0,021 ponto por hora e o R² 0,001 — o modelo reencontrou exatamente o que foi posto nos dados, ou seja, nada. A conclusão do aluno é sobre o gerador, não sobre o estudo. Para a aula mostrar uma relação, ela precisa estar no gerador: o artigo passou a calcular a nota a partir das horas, 3.5 + 0.6 * horas mais ruído, e a correlação subiu para 0,72, com inclinação de 0,587 e R² de 0,52. Dados sintéticos servem para testar se o código funciona, não para descobrir coisa alguma sobre o mundo — e vale a lição inversa também: com dados reais, uma correlação forte ainda não diz qual variável causa a outra.

Exercício 5

Um relatório deveria listar os três primeiros funcionários da tabela. O código df.loc[0:3] devolve quatro linhas, e o colega que "corrigiu" para df.loc[0:2] quebrou outra consulta, que usava índice de datas. Qual é a regra e qual é a forma certa de pedir "os três primeiros"?

Ver resposta

✓ Resposta: O loc seleciona por rótulo e inclui as duas pontas; o iloc seleciona por posição e exclui o fim, como o fatiamento de listas do Python. No DataFrame do artigo, df.loc[1:3] devolveu Bruno, Carla e Diego, as linhas 1, 2 e 3, enquanto df.iloc[:3] devolveu as posições 0, 1 e 2. A regra do loc faz sentido para rótulos que não são números — em df.loc["2024-01":"2024-03"], excluir março seria absurdo —, mas engana quando o índice por acaso é 0, 1, 2… e parece posição. "Os três primeiros" é uma pergunta sobre posição, então a resposta é df.iloc[:3] ou df.head(3), que funcionam com qualquer índice. O remendo loc[0:2] só funcionava enquanto o índice fosse 0, 1, 2…, e depois de um filtro ou de uma ordenação ele deixa de ser.

Comentários

Mais em Python

Projeto 3: Bot de Automação com Scraping e Notificações
Projeto 3: Bot de Automação com Scraping e Notificações

Um bot de vagas com coleta pela API do RemoteOK, filtro por pontuação, SQLite…

Templates HTML com Jinja2 e Deploy com Docker
Templates HTML com Jinja2 e Deploy com Docker

Jinja2 no Flask e no FastAPI, e o deploy com Docker e Compose: os filtros que…

Visão Computacional com OpenCV e PIL
Visão Computacional com OpenCV e PIL

Pillow e OpenCV na prática: filtros, certificados, bordas, contornos, faces…