verifyai / ml /extract_features.py
Erich Schlaepfer
backend
87adc05
Raw
History Blame Contribute Delete
11.2 kB
"""
Pipeline de extração de features para treino de modelo próprio.
Lê vídeos de:
dataset/reais/ → label 0
dataset/sinteticos/ → label 1
dataset/cgi/ → label 2
Para cada vídeo extrai 10 frames aleatórios e calcula métricas numéricas.
Salva tudo em dataset_treino.csv.
Uso:
python ml/extract_features.py
python ml/extract_features.py --dataset-dir /caminho/para/dataset --output meu_dataset.csv
"""
import argparse
import csv
import os
import random
import sys
from pathlib import Path
import cv2
import numpy as np
import imageio.v3 as iio
# =============================================
# EXTRAÇÃO DE FRAMES
# =============================================
def extract_random_frames(video_path: str, n_frames: int = 10) -> list[np.ndarray]:
"""Extrai N frames aleatórios de um vídeo via imageio (suporta MP4/H.264)."""
# Conta frames
total = 0
for _ in iio.imiter(video_path, plugin="pyav"):
total += 1
if total == 0:
return []
# Seleciona índices aleatórios ordenados
n = min(n_frames, total)
indices = sorted(random.sample(range(total), n))
target_set = set(indices)
frames = []
for i, frame in enumerate(iio.imiter(video_path, plugin="pyav")):
if i in target_set:
frames.append(frame)
if len(frames) >= n:
break
return frames
# =============================================
# CÁLCULO DE FEATURES
# =============================================
def compute_features(frame_rgb: np.ndarray) -> dict:
"""
Calcula todas as features numéricas de um único frame.
Retorna dict com nomes de coluna → valor.
"""
gray = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2GRAY)
features = {}
# --- 1. PRNU (ruído de sensor) ---
prnu = _compute_prnu(gray)
features["prnu_mean"] = prnu["mean"]
features["prnu_std"] = prnu["std"]
features["prnu_energy"] = prnu["energy"]
# --- 2. FFT (picos de alta frequência) ---
fft = _compute_fft(gray)
features["fft_high_ratio"] = fft["high_ratio"]
features["fft_mid_ratio"] = fft["mid_ratio"]
features["fft_peak_intensity"] = fft["peak_intensity"]
features["fft_spectral_entropy"] = fft["spectral_entropy"]
# --- 3. Laplaciano (borrão / nitidez) ---
lap = _compute_laplacian(gray)
features["laplacian_var"] = lap["variance"]
features["laplacian_mean"] = lap["mean"]
features["laplacian_kurtosis"] = lap["kurtosis"]
# --- 4. Histograma de Cores (flat colors / CGI) ---
hist = _compute_color_histogram(frame_rgb)
features["color_unique_bins"] = hist["unique_bins"]
features["color_dominant_ratio"] = hist["dominant_ratio"]
features["color_entropy"] = hist["entropy"]
features["color_flatness"] = hist["flatness"]
# --- 5. Textura (variância local) ---
tex = _compute_texture(gray)
features["texture_smooth_ratio"] = tex["smooth_ratio"]
features["texture_var_mean"] = tex["var_mean"]
features["texture_var_std"] = tex["var_std"]
# --- 6. Bordas (nitidez de contorno) ---
edge = _compute_edges(gray)
features["edge_density"] = edge["density"]
features["edge_strong_ratio"] = edge["strong_ratio"]
return features
def _compute_prnu(gray: np.ndarray) -> dict:
"""Estima ruído residual (PRNU) via filtro de Wiener simplificado."""
# Suaviza para obter a "imagem limpa"
denoised = cv2.GaussianBlur(gray.astype(np.float64), (5, 5), 0)
noise = gray.astype(np.float64) - denoised
return {
"mean": float(np.mean(np.abs(noise))),
"std": float(np.std(noise)),
"energy": float(np.mean(noise ** 2)),
}
def _compute_fft(gray: np.ndarray) -> dict:
"""Análise de frequência via FFT 2D."""
f = np.fft.fft2(gray.astype(np.float32))
f_shift = np.fft.fftshift(f)
magnitude = np.log1p(np.abs(f_shift))
h, w = magnitude.shape
cy, cx = h // 2, w // 2
max_r = min(cy, cx)
Y, X = np.ogrid[:h, :w]
dist = np.sqrt((X - cx) ** 2 + (Y - cy) ** 2)
low = dist <= (max_r * 0.2)
mid = (dist > (max_r * 0.2)) & (dist <= (max_r * 0.6))
high = dist > (max_r * 0.6)
total_energy = float(np.sum(magnitude)) + 1e-10
high_energy = float(np.sum(magnitude[high]))
mid_energy = float(np.sum(magnitude[mid]))
# Entropia espectral (distribuição de energia)
mag_flat = magnitude.flatten()
mag_norm = mag_flat / (mag_flat.sum() + 1e-10)
mag_norm = mag_norm[mag_norm > 0]
entropy = float(-np.sum(mag_norm * np.log2(mag_norm + 1e-15)))
return {
"high_ratio": high_energy / total_energy,
"mid_ratio": mid_energy / total_energy,
"peak_intensity": float(np.max(magnitude[high])) if np.any(high) else 0,
"spectral_entropy": entropy,
}
def _compute_laplacian(gray: np.ndarray) -> dict:
"""Variância do Laplaciano — mede nível de borrão."""
lap = cv2.Laplacian(gray, cv2.CV_64F)
flat = lap.flatten()
var = float(np.var(flat))
mean = float(np.mean(np.abs(flat)))
# Curtose: distribuição das bordas (IA tende a ser platikúrtica)
centered = flat - np.mean(flat)
m4 = float(np.mean(centered ** 4))
m2 = float(np.var(flat))
kurtosis = (m4 / (m2 ** 2 + 1e-10)) - 3 # excesso de curtose
return {
"variance": var,
"mean": mean,
"kurtosis": kurtosis,
}
def _compute_color_histogram(frame_rgb: np.ndarray) -> dict:
"""Analisa distribuição de cores para detectar flat colors (CGI/anime)."""
small = cv2.resize(frame_rgb, (160, 90))
pixels = small.reshape(-1, 3)
# Quantiza para bins de 16
quantized = (pixels // 16).astype(np.int32)
unique_colors = len(set(map(tuple, quantized)))
# Histograma HSV
hsv = cv2.cvtColor(small, cv2.COLOR_RGB2HSV)
hist_h = cv2.calcHist([hsv], [0], None, [32], [0, 180]).flatten()
hist_s = cv2.calcHist([hsv], [1], None, [32], [0, 256]).flatten()
hist_v = cv2.calcHist([hsv], [2], None, [32], [0, 256]).flatten()
combined = np.concatenate([hist_h, hist_s, hist_v])
combined_norm = combined / (combined.sum() + 1e-10)
# Entropia de cor
nonzero = combined_norm[combined_norm > 0]
entropy = float(-np.sum(nonzero * np.log2(nonzero + 1e-15)))
# Dominância: fração coberta pelos top 5 bins
sorted_bins = np.sort(combined_norm)[::-1]
dominant_ratio = float(np.sum(sorted_bins[:5]))
# Flatness: proporção de pixels em blocos "flat" (variância local < 10)
gray = cv2.cvtColor(small, cv2.COLOR_RGB2GRAY).astype(np.float32)
local_mean = cv2.blur(gray, (8, 8))
local_sqmean = cv2.blur(gray ** 2, (8, 8))
local_var = np.maximum(0, local_sqmean - local_mean ** 2)
flatness = float(np.mean(local_var < 10))
return {
"unique_bins": unique_colors,
"dominant_ratio": dominant_ratio,
"entropy": entropy,
"flatness": flatness,
}
def _compute_texture(gray: np.ndarray) -> dict:
"""Variância local — mede uniformidade de texturas."""
g = gray.astype(np.float32)
mean = cv2.blur(g, (5, 5))
sqmean = cv2.blur(g * g, (5, 5))
local_var = np.maximum(0, sqmean - mean * mean)
smooth_ratio = float(np.mean(local_var < 10))
return {
"smooth_ratio": smooth_ratio,
"var_mean": float(np.mean(local_var)),
"var_std": float(np.std(local_var)),
}
def _compute_edges(gray: np.ndarray) -> dict:
"""Densidade e qualidade de bordas."""
all_edges = cv2.Canny(gray, 30, 80)
strong_edges = cv2.Canny(gray, 100, 200)
all_density = float(np.mean(all_edges > 0))
strong_density = float(np.mean(strong_edges > 0))
return {
"density": all_density,
"strong_ratio": strong_density / (all_density + 1e-10),
}
# =============================================
# PIPELINE PRINCIPAL
# =============================================
FEATURE_COLUMNS = list(compute_features(np.zeros((64, 64, 3), dtype=np.uint8)).keys())
VIDEO_EXTS = {".mp4", ".webm", ".avi", ".mkv", ".mov"}
def process_folder(folder: str, label: int, n_frames: int = 10) -> list[dict]:
"""Processa todos os vídeos de uma pasta, retorna lista de rows."""
folder_path = Path(folder)
if not folder_path.exists():
print(f" [SKIP] Pasta não encontrada: {folder}")
return []
videos = [f for f in folder_path.iterdir() if f.suffix.lower() in VIDEO_EXTS]
print(f" Encontrados {len(videos)} vídeos em {folder}")
rows = []
for i, video_path in enumerate(videos):
try:
frames = extract_random_frames(str(video_path), n_frames)
if not frames:
print(f" [{i+1}/{len(videos)}] {video_path.name}: sem frames, pulando")
continue
for j, frame in enumerate(frames):
features = compute_features(frame)
features["video_file"] = video_path.name
features["frame_index"] = j
features["label"] = label
rows.append(features)
print(f" [{i+1}/{len(videos)}] {video_path.name}: {len(frames)} frames OK")
except Exception as e:
print(f" [{i+1}/{len(videos)}] {video_path.name}: ERRO - {e}")
return rows
def main():
parser = argparse.ArgumentParser(description="Extrai features de vídeos para treino ML")
parser.add_argument("--dataset-dir", default="dataset", help="Pasta raiz do dataset")
parser.add_argument("--output", default="dataset_treino.csv", help="Arquivo CSV de saída")
parser.add_argument("--frames", type=int, default=10, help="Frames por vídeo")
args = parser.parse_args()
base = Path(args.dataset_dir)
folders = [
(base / "reais", 0, "Real"),
(base / "sinteticos", 1, "IA Generativa"),
(base / "cgi", 2, "CGI/Animação"),
]
all_rows = []
for folder, label, name in folders:
print(f"\n{'='*50}")
print(f"Processando: {name} (label={label}) — {folder}")
print(f"{'='*50}")
rows = process_folder(str(folder), label, args.frames)
all_rows.extend(rows)
print(f" Total: {len(rows)} amostras")
if not all_rows:
print("\nNenhuma amostra extraída. Verifique se as pastas existem e contêm vídeos.")
sys.exit(1)
# Salva CSV
fieldnames = ["video_file", "frame_index"] + FEATURE_COLUMNS + ["label"]
output_path = Path(args.output)
with open(output_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(all_rows)
print(f"\n{'='*50}")
print(f"Dataset salvo: {output_path}")
print(f"Total de amostras: {len(all_rows)}")
# Resumo por classe
from collections import Counter
counts = Counter(r["label"] for r in all_rows)
label_names = {0: "Real", 1: "IA", 2: "CGI"}
for label, count in sorted(counts.items()):
print(f" Label {label} ({label_names.get(label, '?')}): {count} amostras")
print(f"Features por amostra: {len(FEATURE_COLUMNS)}")
print(f"Colunas: {', '.join(FEATURE_COLUMNS)}")
if __name__ == "__main__":
main()