Download route/forensic_scope_api.py from Manar55/truscan: direct link, hf CLI and curl.
- Browser
- Download file 5.39 kB
-
https://huggingface.co/spaces/Manar55/truscan/resolve/main/route/forensic_scope_api.py
- Command line
-
hf download hf://spaces/Manar55/truscan/route/forensic_scope_api.py
-
curl -L -o forensic_scope_api.py https://huggingface.co/spaces/Manar55/truscan/resolve/main/route/forensic_scope_api.py
5.39 kB
| """ | |
| forensic_scope_api.py — web-friendly version of forensic_scope.py's core | |
| analysis (ELA, edge detection, local sharpness, noise map). Same exact | |
| math/logic, just with the matplotlib GUI and CLI file-saving stripped out | |
| and replaced with a function that returns base64-encoded PNGs + stats, | |
| ready to be sent back as JSON from a Flask route. | |
| This is a MANUAL VISUAL INSPECTION tool ("magnifier"), not an automated | |
| scoring signal — unlike analysis_engine.py's checks, it doesn't return a | |
| suspicion score. It hands the user visual maps of a region they select, | |
| so THEY judge it, the same way forensic_scope.py's desktop GUI did. | |
| """ | |
| import base64 | |
| import io | |
| import numpy as np | |
| import cv2 | |
| from PIL import Image | |
| def load_image_rgb_from_bytes(data): | |
| img = Image.open(io.BytesIO(data)).convert("RGB") | |
| return np.array(img) | |
| def compute_ela_from_bytes(data, quality=90, scale=15): | |
| """Same ELA logic as forensic_scope.py's compute_ela(), but works | |
| directly from in-memory bytes (no temp file needed for the original, | |
| only for the JPEG-recompressed comparison).""" | |
| orig = Image.open(io.BytesIO(data)).convert("RGB") | |
| buf = io.BytesIO() | |
| orig.save(buf, "JPEG", quality=quality) | |
| buf.seek(0) | |
| resaved = Image.open(buf).convert("RGB") | |
| orig_arr = np.array(orig).astype(np.int16) | |
| resaved_arr = np.array(resaved).astype(np.int16) | |
| diff = np.abs(orig_arr - resaved_arr) | |
| diff = diff * scale | |
| diff = np.clip(diff, 0, 255).astype(np.uint8) | |
| return diff | |
| def compute_edges(gray): | |
| return cv2.Canny(gray, 50, 150) | |
| def compute_local_sharpness(gray, ksize=9): | |
| lap = cv2.Laplacian(gray, cv2.CV_64F) | |
| lap_abs = np.abs(lap) | |
| heat = cv2.GaussianBlur(lap_abs, (ksize, ksize), 0) | |
| heat = heat / (heat.max() + 1e-6) * 255 | |
| return heat.astype(np.uint8) | |
| def compute_noise_map(gray, ksize=5): | |
| blurred = cv2.medianBlur(gray, ksize) | |
| noise = cv2.absdiff(gray, blurred) | |
| noise = cv2.normalize(noise, None, 0, 255, cv2.NORM_MINMAX) | |
| return noise | |
| def compute_stats(gray, sharpness_map, noise_map): | |
| return { | |
| "sharpness_mean": float(np.mean(sharpness_map)), | |
| "sharpness_std": float(np.std(sharpness_map)), | |
| "noise_mean": float(np.mean(noise_map)), | |
| "noise_std": float(np.std(noise_map)), | |
| "brightness_mean": float(np.mean(gray)), | |
| } | |
| def _encode_png_base64(arr, colormap=None): | |
| """Encodes a numpy array (grayscale or RGB) as a base64 PNG data URL.""" | |
| if arr is None: | |
| return None | |
| if colormap is not None: | |
| # Apply an OpenCV colormap for heatmap-style visuals (edges/ | |
| # sharpness/noise look much clearer in color than raw grayscale). | |
| arr_uint8 = arr.astype(np.uint8) if arr.dtype != np.uint8 else arr | |
| colored = cv2.applyColorMap(arr_uint8, colormap) | |
| success, buf = cv2.imencode(".png", colored) | |
| elif arr.ndim == 2: | |
| success, buf = cv2.imencode(".png", arr) | |
| else: | |
| success, buf = cv2.imencode(".png", cv2.cvtColor(arr, cv2.COLOR_RGB2BGR)) | |
| if not success: | |
| return None | |
| b64 = base64.b64encode(buf.tobytes()).decode("ascii") | |
| return f"data:image/png;base64,{b64}" | |
| def analyze_region_for_web(image_bytes, box=None, ela_quality=90, ela_scale=15, zoom_factor=4): | |
| """ | |
| Runs the same forensic maps as forensic_scope.py's analyze_region(), | |
| on an in-memory image, and returns everything as base64 PNG data URLs | |
| + stats — directly JSON-serializable for a Flask response. | |
| box: optional (x1, y1, x2, y2) in ORIGINAL image pixel coordinates. | |
| If None, analyzes the whole image. | |
| """ | |
| rgb_full = load_image_rgb_from_bytes(image_bytes) | |
| full_ela = compute_ela_from_bytes(image_bytes, quality=ela_quality, scale=ela_scale) | |
| if box is not None: | |
| x1, y1, x2, y2 = [int(v) for v in box] | |
| x1, x2 = sorted((x1, x2)) | |
| y1, y2 = sorted((y1, y2)) | |
| crop = rgb_full[y1:y2, x1:x2].copy() | |
| ela_crop = full_ela[y1:y2, x1:x2] | |
| else: | |
| crop = rgb_full.copy() | |
| ela_crop = full_ela | |
| if crop.size == 0: | |
| raise ValueError("Selected region is empty — check the coordinates") | |
| gray = cv2.cvtColor(crop, cv2.COLOR_RGB2GRAY) | |
| zoomed = cv2.resize(crop, None, fx=zoom_factor, fy=zoom_factor, interpolation=cv2.INTER_CUBIC) | |
| edges = cv2.resize(compute_edges(gray), None, fx=zoom_factor, fy=zoom_factor, interpolation=cv2.INTER_NEAREST) | |
| sharpness = cv2.resize(compute_local_sharpness(gray), None, fx=zoom_factor, fy=zoom_factor, interpolation=cv2.INTER_CUBIC) | |
| noise = cv2.resize(compute_noise_map(gray), None, fx=zoom_factor, fy=zoom_factor, interpolation=cv2.INTER_CUBIC) | |
| ela_zoomed = cv2.resize(ela_crop, None, fx=zoom_factor, fy=zoom_factor, interpolation=cv2.INTER_CUBIC) | |
| stats_gray = cv2.cvtColor(crop, cv2.COLOR_RGB2GRAY) | |
| sharpness_raw = compute_local_sharpness(stats_gray) | |
| noise_raw = compute_noise_map(stats_gray) | |
| stats = compute_stats(stats_gray, sharpness_raw, noise_raw) | |
| return { | |
| "zoomed": _encode_png_base64(zoomed), | |
| "edges": _encode_png_base64(edges, colormap=None), | |
| "sharpness": _encode_png_base64(sharpness, colormap=cv2.COLORMAP_INFERNO), | |
| "noise": _encode_png_base64(noise, colormap=cv2.COLORMAP_INFERNO), | |
| "ela": _encode_png_base64(ela_zoomed), | |
| "stats": stats, | |
| "box": list(box) if box else None, | |
| } | |