UAIDE / video_bundle /detector.py
ATS-27's picture
Upload folder using huggingface_hub
af980d7 verified
Raw
History Blame Contribute Delete
8.31 kB
import os
import sys
import argparse
from glob import glob
from pathlib import Path
import numpy as np
from PIL import Image
from scipy.ndimage import gaussian_filter
import numpy.fft as fft
from skimage.feature import local_binary_pattern
import matplotlib.pyplot as plt
def load_image(path):
img = Image.open(path).convert('RGB')
arr = np.asarray(img).astype(np.float32) / 255.0
# keep imanmhjge sizes bounded to avoid extremely large images slowing feature extraction
max_side = 1024
h, w = arr.shape[:2]
scale = min(1.0, float(max_side) / max(h, w))
if scale != 1.0:
new_w = int(round(w * scale))
new_h = int(round(h * scale))
img = img.resize((new_w, new_h), resample=Image.LANCZOS)
arr = np.asarray(img).astype(np.float32) / 255.0
return arr
def rgb_to_gray(img):
# img: HxWx3 in [0,1]
return np.clip(0.2989 * img[..., 0] + 0.5870 * img[..., 1] + 0.1140 * img[..., 2], 0.0, 1.0)
def extract_residual(gray, sigma=1.5):
blurred = gaussian_filter(gray, sigma=sigma)
residual = gray - blurred
# normalize
m = residual.mean()
s = residual.std() + 1e-12
residual = (residual - m) / s
return residual
def fft_stats(gray):
# compute log-magnitude spectrum and a simple high-frequency ratio
H, W = gray.shape
F = fft.fft2(gray)
Fshift = fft.fftshift(F)
Fmag = np.log1p(np.abs(Fshift))
# radial profile -> compute high-frequency energy ratio
cy, cx = H // 2, W // 2
Y, X = np.ogrid[:H, :W]
R = np.sqrt((Y - cy) ** 2 + (X - cx) ** 2)
r_norm = R / R.max()
hf_mask = r_norm > 0.5
hf_ratio = Fmag[hf_mask].sum() / (Fmag.sum() + 1e-12)
return Fmag, float(hf_ratio)
def lbp_entropy(patch_gray, P=8, R=1):
# local_binary_pattern works more predictably on integer images (uint8).
# Convert floating gray [0,1] to uint8 0..255 to avoid numerical issues and speed up histogramming.
img_uint8 = (np.clip(patch_gray, 0.0, 1.0) * 255.0).astype(np.uint8)
lbp = local_binary_pattern(img_uint8, P=P, R=R, method='uniform')
# histogram using bincount for speed (lbp values are small integers for 'uniform' method)
lbp_flat = lbp.ravel().astype(np.int32)
n_bins = int(lbp_flat.max() + 1)
if n_bins <= 0:
return 0.0
counts = np.bincount(lbp_flat, minlength=n_bins).astype(np.float32)
probs = counts / (counts.sum() + 1e-12)
probs = probs + 1e-12
ent = -np.sum(probs * np.log(probs))
return float(ent)
def sliding_patch_scores(img_rgb, patch_size=128, stride=64):
H, W, _ = img_rgb.shape
gray = rgb_to_gray(img_rgb)
residual_full = extract_residual(gray)
Fmag_full, _ = fft_stats(gray)
scores = []
coords = []
for y in range(0, max(1, H - patch_size + 1), stride):
for x in range(0, max(1, W - patch_size + 1), stride):
patch = img_rgb[y:y + patch_size, x:x + patch_size]
if patch.shape[0] != patch_size or patch.shape[1] != patch_size:
# pad
ph = np.zeros((patch_size, patch_size, 3), dtype=patch.dtype)
ph[:patch.shape[0], :patch.shape[1]] = patch
patch = ph
pg = rgb_to_gray(patch)
pres = extract_residual(pg)
# residual score: std (higher natural noise -> more likely real)
residual_score = np.clip(np.std(pres), 0.0, 10.0)
# frequency score using patch FFT
_, hf = fft_stats(pg)
# texture score: entropy directly (higher entropy -> more likely real)
ent = lbp_entropy(pg)
scores.append((residual_score, hf, ent))
coords.append((y, x))
# Convert to arrays
arr = np.array(scores) # N x 3
# Normalize each column to 0..1
mins = arr.min(axis=0)
maxs = arr.max(axis=0)
ranges = (maxs - mins) + 1e-12
norm = (arr - mins) / ranges
# Map signals to an AI-likelihood:
# normalized values: high = real (residual std, HF content, entropy), low = AI
residual_norm = norm[:, 0] # High std => real
freq_norm = norm[:, 1] # High HF => real
entropy_norm = norm[:, 2] # High entropy => real
# AI likelihood: invert all real indicators
w_res, w_freq, w_tex = 0.35, 0.15, 0.50
patch_ai = (1.0 - residual_norm) * w_res + (1.0 - freq_norm) * w_freq + (1.0 - entropy_norm) * w_tex
return patch_ai, coords, (H, W), patch_size, stride
def reconstruct_heatmap(patch_scores, coords, image_shape, patch_size, stride):
H, W = image_shape
heat = np.zeros((H, W), dtype=np.float32)
count = np.zeros((H, W), dtype=np.float32)
for s, (y, x) in zip(patch_scores, coords):
y2 = min(H, y + patch_size)
x2 = min(W, x + patch_size)
h = y2 - y
w = x2 - x
heat[y:y2, x:x2] += s
count[y:y2, x:x2] += 1.0
count[count == 0] = 1.0
heat = heat / count
# smooth
heat = gaussian_filter(heat, sigma=patch_size / 4.0)
# normalize
heat = (heat - heat.min()) / (heat.max() - heat.min() + 1e-12)
return heat
def overlay_and_save(orig_rgb, heatmap, out_path, alpha=0.5, cmap='jet'):
plt.figure(figsize=(8, 8))
plt.imshow(orig_rgb)
plt.imshow(heatmap, cmap=cmap, alpha=alpha, vmin=0, vmax=1)
plt.axis('off')
plt.tight_layout()
plt.savefig(out_path, bbox_inches='tight', pad_inches=0)
plt.close()
def process_image(path, out_dir=None, patch_size=128, stride=64, invert=False):
img = load_image(path)
patch_scores, coords, img_shape, ps, st = sliding_patch_scores(img, patch_size=patch_size, stride=stride)
heat = reconstruct_heatmap(patch_scores, coords, img_shape, ps, st)
if invert:
heat = 1.0 - heat
ai_score = float(np.mean(heat))
if out_dir:
os.makedirs(out_dir, exist_ok=True)
fname = Path(path).stem + '_heat.png'
out_path = os.path.join(out_dir, fname)
overlay_and_save(np.clip(img, 0, 1), heat, out_path)
return {'ai_score': ai_score, 'heatmap': heat}
def scan_dataset(dataset_path, out_dir, max_images=None, **kwargs):
patterns = ['**/*.jpg', '**/*.jpeg', '**/*.png', '**/*.bmp']
p = Path(dataset_path)
files = []
for pat in patterns:
files.extend(p.glob(pat))
files = [str(x) for x in sorted(files)]
if max_images:
files = files[:max_images]
results = []
for i, f in enumerate(files):
try:
res = process_image(f, out_dir=out_dir, **kwargs)
results.append((f, res))
except Exception as e:
print(f'Failed {f}: {e}', file=sys.stderr)
return results
def cli():
parser = argparse.ArgumentParser()
parser.add_argument('--image', type=str, help='path to single image')
parser.add_argument('--dataset', type=str, help='path to dataset folder')
parser.add_argument('--out_dir', type=str, default='out', help='where to write overlays')
parser.add_argument('--max_images', type=int, default=200, help='max images to process when scanning dataset')
parser.add_argument('--patch', type=int, default=128)
parser.add_argument('--stride', type=int, default=64)
parser.add_argument('--threshold', type=float, default=0.6, help='AI score threshold for labeling')
parser.add_argument('--invert', action='store_true', help='invert heatmap/score (use if real images look AI)')
args = parser.parse_args()
if args.image:
res = process_image(
args.image,
out_dir=args.out_dir,
patch_size=args.patch,
stride=args.stride,
invert=args.invert,
)
label = 'AI' if res['ai_score'] >= args.threshold else 'REAL'
print(f"ai_score: {res['ai_score']:.4f}")
print(f"label: {label} (threshold={args.threshold:.2f}, invert={args.invert})")
elif args.dataset:
print('Scanning dataset, this may take a while...')
res = scan_dataset(
args.dataset,
out_dir=args.out_dir,
max_images=args.max_images,
patch_size=args.patch,
stride=args.stride,
invert=args.invert,
)
print(f'Processed {len(res)} images, overlays in {args.out_dir}')
else:
parser.print_help()
if __name__ == '__main__':
cli()