""" Multi-Modal RAG - Vision + Text retrieval for crypto security. Capabilities: 1. Token logo analysis - stolen artwork detection via perceptual hashing 2. Screenshot OCR - extract addresses, contracts, amounts from images 3. Chart pattern analysis - detect pump/dump, rug pull patterns in price charts Uses: - Perceptual hashing (pHash) for image similarity - zero API cost - DeepSeek v4 Flash vision for description/captioning (via LLM_API_KEY) - Text embedding via existing BGE-small for cross-modal retrieval Designed to work with existing RAG pipeline: images → descriptions → embed → search. """ import hashlib import logging import os from typing import Any logger = logging.getLogger(__name__) # ── Perceptual Hashing ─────────────────────────────────────────── def compute_image_hash(image_bytes: bytes, hash_size: int = 16) -> str: """ Compute perceptual hash (pHash) of an image. Uses average hashing - resize to hash_sizexhash_size, convert to grayscale, # noqa: RUF002 compute average, threshold each pixel. Returns hex string. Pure Python implementation - no OpenCV/pillow dependency. """ # Parse minimal BMP/PNG header to get pixel data try: # Use Python's built-in imghdr to detect type import imghdr img_type = imghdr.what(None, image_bytes) if img_type == "png": import struct import zlib # Minimal PNG parser for pHash # Skip to IDAT chunks pos = 8 # Skip PNG signature width = height = 0 pixel_data = b"" while pos < len(image_bytes): chunk_len = struct.unpack(">I", image_bytes[pos : pos + 4])[0] chunk_type = image_bytes[pos + 4 : pos + 8].decode("ascii", errors="ignore") pos += 8 if chunk_type == "IHDR": width = struct.unpack(">I", image_bytes[pos : pos + 4])[0] height = struct.unpack(">I", image_bytes[pos + 4 : pos + 8])[0] elif chunk_type == "IDAT": pixel_data += image_bytes[pos : pos + chunk_len] elif chunk_type == "IEND": break pos += chunk_len + 4 # +4 for CRC if pixel_data: try: decompressed = zlib.decompress(pixel_data) # Convert to 16x16 grayscale by averaging blocks return _hash_from_raw(decompressed, width, height, hash_size) except zlib.error: pass # Fallback: hash raw bytes (file-level similarity) return _hash_from_raw(image_bytes, 1, len(image_bytes), hash_size) except Exception: pass # Ultimate fallback: SHA-based hash of raw bytes return hashlib.sha256(image_bytes).hexdigest()[: hash_size * 2] def _hash_from_raw(data: bytes, width: int, height: int, hash_size: int) -> str: """Compute average hash from raw pixel data.""" try: list(data) block_w = max(1, width // hash_size) block_h = max(1, height // hash_size) hash_bits = [] for y in range(hash_size): for x in range(hash_size): # Average pixels in this block block_sum = 0 block_count = 0 for by in range(y * block_h, min((y + 1) * block_h, height)): for bx in range(x * block_w, min((x + 1) * block_w, width)): idx = (by * width + bx) * 3 # Assume RGB if idx + 2 < len(data): # Grayscale: 0.299*R + 0.587*G + 0.114*B gray = int(0.299 * data[idx] + 0.587 * data[idx + 1] + 0.114 * data[idx + 2]) block_sum += gray block_count += 1 block_avg = block_sum / max(block_count, 1) hash_bits.append(block_avg) # Compute average and threshold total_avg = sum(hash_bits) / len(hash_bits) bits = "".join("1" if b > total_avg else "0" for b in hash_bits) # Convert to hex return hex(int(bits, 2))[2:].zfill(hash_size * 2 // 4) except Exception: return hashlib.sha256(data).hexdigest()[: hash_size * 2] def compare_image_hashes(hash1: str, hash2: str) -> float: """Compare two perceptual hashes. Returns similarity 0-1.""" if len(hash1) != len(hash2): return 0.0 try: h1 = int(hash1, 16) h2 = int(hash2, 16) xor = h1 ^ h2 # Count differing bits diff_bits = bin(xor).count("1") max_bits = len(hash1) * 4 return 1.0 - (diff_bits / max_bits) except ValueError: return 0.0 # ── Token Logo Database ─────────────────────────────────────────── class TokenLogoDB: """In-memory database of known token logos for stolen artwork detection.""" def __init__(self): self._logos: dict[str, dict[str, Any]] = {} def add_logo( self, token_symbol: str, token_address: str, chain: str, image_hash: str, source: str = "unknown", ) -> None: """Register a token logo hash.""" self._logos[token_address.lower()] = { "symbol": token_symbol, "address": token_address, "chain": chain, "hash": image_hash, "source": source, } def find_similar(self, image_hash: str, threshold: float = 0.85) -> list[dict[str, Any]]: """Find logos similar to the given hash. Returns matches above threshold.""" matches = [] for _addr, logo in self._logos.items(): sim = compare_image_hashes(image_hash, logo["hash"]) if sim >= threshold: matches.append({**logo, "similarity": round(sim, 3)}) matches.sort(key=lambda m: m["similarity"], reverse=True) return matches def check_theft(self, image_hash: str) -> dict[str, Any]: """ Check if a token logo appears to be stolen from a known project. Returns analysis with confidence and matched original projects. """ matches = self.find_similar(image_hash, threshold=0.90) very_high = [m for m in matches if m["similarity"] >= 0.95] high = [m for m in matches if 0.90 <= m["similarity"] < 0.95] if very_high: return { "suspected_theft": True, "confidence": "high", "matched_logos": very_high, "summary": f"Logo appears stolen from {very_high[0]['symbol']} " f"({very_high[0]['chain']}) - {very_high[0]['similarity']:.1%} similarity", } elif high: return { "suspected_theft": True, "confidence": "medium", "matched_logos": high, "summary": f"Logo similar to {high[0]['symbol']} " f"({high[0]['chain']}) - {high[0]['similarity']:.1%} similarity", } return { "suspected_theft": False, "confidence": "none", "matched_logos": matches, "summary": "No stolen artwork detected", } # ── Image Description (via DeepSeek Vision) ────────────────────── async def describe_image( image_url: str = "", image_base64: str = "", task: str = "describe", ) -> str: """ Generate a text description of an image using DeepSeek v4 Flash vision. Uses the existing LLM_API_KEY configuration. task: "describe" | "extract_addresses" | "analyze_chart" | "detect_scam" """ try: from app.rag_agentic import AI_BASE, LLM_API_KEY if not LLM_API_KEY: logger.warning("No LLM key available for image description") return "Image description unavailable - no LLM API key configured" TASK_PROMPTS = { "describe": "Describe this image in detail. What crypto-related content does it show?", "extract_addresses": ( "Extract any cryptocurrency wallet addresses (0x... for EVM, " "base58 for Solana, etc.), contract addresses, transaction hashes, " "and token symbols visible in this image. Return them as a JSON list " 'with format: {"addresses": [...], "symbols": [...], "chains": [...]}' ), "analyze_chart": ( "Analyze this price chart. Does it show signs of a pump-and-dump, " "rug pull, or wash trading? Look for: sudden spikes, immediate crashes, " "flat volume then massive spike, stair-step patterns." ), "detect_scam": ( "Is this screenshot showing a potential crypto scam? Check for: " "fake UI elements, too-good-to-be-true returns, impersonation of " "legitimate platforms, urgency tactics, grammar errors." ), } prompt = TASK_PROMPTS.get(task, TASK_PROMPTS["describe"]) payload = { "model": os.getenv("LLM_MODEL", "deepseek-v4-flash"), "messages": [{"role": "user", "content": prompt}], "max_tokens": 300, } if image_url: payload["messages"][0]["content"] = [ {"type": "image_url", "image_url": {"url": image_url}}, {"type": "text", "text": prompt}, ] elif image_base64: payload["messages"][0]["content"] = [ { "type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}, }, {"type": "text", "text": prompt}, ] else: return "No image provided" import httpx async with httpx.AsyncClient(timeout=30) as client: resp = await client.post( AI_BASE, headers={ "Authorization": f"Bearer {LLM_API_KEY}", "Content-Type": "application/json", }, json=payload, ) if resp.status_code == 200: data = resp.json() return data["choices"][0]["message"]["content"] else: logger.warning(f"Image description failed: HTTP {resp.status_code}") return f"Image analysis error: HTTP {resp.status_code}" except ImportError: logger.debug("LLM module not available for vision") return "Vision analysis unavailable" except Exception as e: logger.warning(f"Image description error: {e}") return f"Image analysis failed: {e}" # ── Screenshot OCR - lightweight text extraction ───────────────── def extract_text_from_image_hints(image_bytes: bytes) -> dict[str, list[str]]: """ Lightweight heuristic text extraction from image bytes. Searches for crypto address patterns, URLs, and keywords. For full OCR, use describe_image(task='extract_addresses') with DeepSeek vision. """ text = "" try: # Try to extract printable ASCII from the raw bytes text = "".join(chr(b) for b in image_bytes if 32 <= b < 127) # Limit to first 10K chars text = text[:10000] except Exception: pass results = { "eth_addresses": [], "sol_addresses": [], "urls": [], "amounts": [], } import re # ETH addresses eth_matches = re.findall(r"0x[a-fA-F0-9]{40}", text) results["eth_addresses"] = list(set(eth_matches))[:20] # Solana addresses sol_matches = re.findall(r"[1-9A-HJ-NP-Za-km-z]{32,44}", text) results["sol_addresses"] = list(set(sol_matches))[:20] # URLs url_matches = re.findall(r"https?://[^\s]+", text) results["urls"] = list(set(url_matches))[:10] # Dollar amounts amount_matches = re.findall(r"\$[\d,]+(?:\.\d+)?", text) results["amounts"] = amount_matches[:10] return results # ── Singleton ───────────────────────────────────────────────────── _logo_db: TokenLogoDB | None = None def get_logo_db() -> TokenLogoDB: global _logo_db if _logo_db is None: _logo_db = TokenLogoDB() return _logo_db