| 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 |
| |
| 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): |
| |
| 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 |
| |
| m = residual.mean() |
| s = residual.std() + 1e-12 |
| residual = (residual - m) / s |
| return residual |
|
|
|
|
| def fft_stats(gray): |
| |
| H, W = gray.shape |
| F = fft.fft2(gray) |
| Fshift = fft.fftshift(F) |
| Fmag = np.log1p(np.abs(Fshift)) |
| |
| 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): |
| |
| |
| 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') |
| |
| 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: |
| |
| 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 = np.clip(np.std(pres), 0.0, 10.0) |
| |
| _, hf = fft_stats(pg) |
| |
| ent = lbp_entropy(pg) |
| scores.append((residual_score, hf, ent)) |
| coords.append((y, x)) |
|
|
| |
| arr = np.array(scores) |
| |
| mins = arr.min(axis=0) |
| maxs = arr.max(axis=0) |
| ranges = (maxs - mins) + 1e-12 |
| norm = (arr - mins) / ranges |
|
|
| |
| |
| residual_norm = norm[:, 0] |
| freq_norm = norm[:, 1] |
| entropy_norm = norm[:, 2] |
| |
| |
| 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 |
| |
| heat = gaussian_filter(heat, sigma=patch_size / 4.0) |
| |
| 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() |
|
|