""" Gemini Watermark Removal Tool Removes visible Gemini watermarks using reverse alpha blending. Based on the mathematical formula: watermarked = α × logo + (1 - α) × original Solving for original: original = (watermarked - α × logo) / (1 - α) Python port with embedded alpha maps. Usage: python GWMRTool.py image.jpg python GWMRTool.py -i input.jpg -o output.jpg python GWMRTool.py -i ./input_folder/ -o ./output_folder/ """ import numpy as np from PIL import Image import argparse from pathlib import Path import sys import os from typing import Tuple, Optional # ============================================================================ # EMBEDDED ALPHA MAPS # ============================================================================ # Default paths for alpha maps (relative to script location) SCRIPT_DIR = Path(__file__).parent.resolve() DEFAULT_ALPHA_MAP_SMALL_PATH = SCRIPT_DIR / "bg_48.png" DEFAULT_ALPHA_MAP_LARGE_PATH = SCRIPT_DIR / "bg_96.png" class AlphaMapManager: """ Manages loading and caching of alpha maps. """ _cache = {} @classmethod def load_alpha_map(cls, path: Path, expected_size: int) -> np.ndarray: """ Load alpha map from file with caching. Args: path: Path to alpha map image expected_size: Expected size (48 or 96) Returns: Alpha map as float32 array [0, 1] """ cache_key = str(path) if cache_key in cls._cache: return cls._cache[cache_key] if not path.exists(): raise FileNotFoundError(f"Alpha map not found: {path}") # Load image img = Image.open(path) img_array = np.array(img) # Calculate alpha map using max of RGB channels if len(img_array.shape) == 3: if img_array.shape[2] >= 3: # Use max of RGB channels for brightness gray = np.max(img_array[:, :, :3], axis=2) else: gray = img_array[:, :, 0] else: gray = img_array # Normalize to [0, 1] alpha_map = gray.astype(np.float32) / 255.0 # Validate size if alpha_map.shape[0] != expected_size or alpha_map.shape[1] != expected_size: print(f"Warning: Alpha map size {alpha_map.shape} doesn't match expected {expected_size}x{expected_size}") # Resize if needed img_resized = Image.fromarray(gray).resize( (expected_size, expected_size), Image.Resampling.LANCZOS ) alpha_map = np.array(img_resized).astype(np.float32) / 255.0 # Cache and return cls._cache[cache_key] = alpha_map return alpha_map @classmethod def clear_cache(cls): """Clear the alpha map cache.""" cls._cache.clear() # ============================================================================ # BLEND MODES (Translated from C++ blend_modes.cpp) # ============================================================================ class BlendModes: """ Alpha blending operations for watermark removal/addition. Direct translation from C++ blend_modes.cpp """ # Constants from C++ code ALPHA_THRESHOLD = 0.002 # Ignore very small alpha (noise) MAX_ALPHA = 0.99 # Avoid division by near-zero @staticmethod def calculate_alpha_map(bg_capture: np.ndarray) -> np.ndarray: """ Calculate alpha map from background capture. Uses max of RGB channels for brightness (handles colored logos). Args: bg_capture: Background capture image (grayscale or RGB/RGBA) Returns: Alpha map as float32 array normalized to [0, 1] """ if bg_capture is None or bg_capture.size == 0: raise ValueError("Empty background capture") # Handle different channel configurations if len(bg_capture.shape) == 3: if bg_capture.shape[2] >= 3: # RGB/RGBA: Use max of RGB channels for brightness gray = np.max(bg_capture[:, :, :3], axis=2) else: gray = bg_capture[:, :, 0] else: # Already grayscale gray = bg_capture.copy() # Convert to float and normalize to [0, 1] alpha_map = gray.astype(np.float32) / 255.0 return alpha_map @staticmethod def remove_watermark_alpha_blend( image: np.ndarray, alpha_map: np.ndarray, position: Tuple[int, int], logo_value: float = 255.0 ) -> np.ndarray: """ Remove watermark using reverse alpha blending (vectorized). Gemini applies: watermarked = alpha * logo + (1 - alpha) * original We reverse: original = (watermarked - alpha * logo) / (1 - alpha) Args: image: Input image (RGB, uint8) alpha_map: Alpha map (float32, normalized to [0, 1]) position: (x, y) position of watermark top-left corner logo_value: Logo pixel value (default 255 for white logo) Returns: Image with watermark removed """ if image is None or image.size == 0: raise ValueError("Empty image") if alpha_map is None or alpha_map.size == 0: raise ValueError("Empty alpha map") if len(image.shape) != 3 or image.shape[2] != 3: raise ValueError("Image must be RGB (3 channels)") if alpha_map.dtype != np.float32: alpha_map = alpha_map.astype(np.float32) # Make a copy to avoid modifying original result = image.copy() # Calculate region of interest x, y = position h, w = alpha_map.shape[:2] # Clip to image bounds x1 = max(0, x) y1 = max(0, y) x2 = min(image.shape[1], x + w) y2 = min(image.shape[0], y + h) if x1 >= x2 or y1 >= y2: return result # Calculate ROI offsets alpha_x1 = x1 - x alpha_y1 = y1 - y alpha_x2 = alpha_x1 + (x2 - x1) alpha_y2 = alpha_y1 + (y2 - y1) # Get ROIs alpha_region = alpha_map[alpha_y1:alpha_y2, alpha_x1:alpha_x2] image_region = result[y1:y2, x1:x2] # Convert image region to float image_f = image_region.astype(np.float32) # Create mask for pixels to process (alpha >= threshold) process_mask = alpha_region >= BlendModes.ALPHA_THRESHOLD # Clamp alpha values alpha_clamped = np.minimum(alpha_region, BlendModes.MAX_ALPHA) one_minus_alpha = 1.0 - alpha_clamped # Avoid division by zero one_minus_alpha = np.maximum(one_minus_alpha, 1e-6) # Expand alpha to 3 channels for broadcasting alpha_3c = alpha_clamped[:, :, np.newaxis] one_minus_alpha_3c = one_minus_alpha[:, :, np.newaxis] process_mask_3c = process_mask[:, :, np.newaxis] # Reverse alpha blending formula: # original = (watermarked - alpha * logo) / (1 - alpha) original = (image_f - alpha_3c * logo_value) / one_minus_alpha_3c original = np.clip(original, 0.0, 255.0) # Only apply to pixels that need processing image_f = np.where(process_mask_3c, original, image_f) # Convert back to 8-bit result[y1:y2, x1:x2] = image_f.astype(np.uint8) return result @staticmethod def add_watermark_alpha_blend( image: np.ndarray, alpha_map: np.ndarray, position: Tuple[int, int], logo_value: float = 255.0 ) -> np.ndarray: """ Add watermark using alpha blending (same as Gemini). Formula: result = alpha * logo + (1 - alpha) * original Args: image: Input image (RGB, uint8) alpha_map: Alpha map (float32, normalized to [0, 1]) position: (x, y) position of watermark top-left corner logo_value: Logo pixel value (default 255 for white logo) Returns: Image with watermark added """ if image is None or image.size == 0: raise ValueError("Empty image") if alpha_map is None or alpha_map.size == 0: raise ValueError("Empty alpha map") if len(image.shape) != 3 or image.shape[2] != 3: raise ValueError("Image must be RGB (3 channels)") if alpha_map.dtype != np.float32: alpha_map = alpha_map.astype(np.float32) # Make a copy to avoid modifying original result = image.copy() # Calculate region of interest x, y = position h, w = alpha_map.shape[:2] # Clip to image bounds x1 = max(0, x) y1 = max(0, y) x2 = min(image.shape[1], x + w) y2 = min(image.shape[0], y + h) if x1 >= x2 or y1 >= y2: return result # Calculate ROI offsets alpha_x1 = x1 - x alpha_y1 = y1 - y alpha_x2 = alpha_x1 + (x2 - x1) alpha_y2 = alpha_y1 + (y2 - y1) # Get ROIs alpha_region = alpha_map[alpha_y1:alpha_y2, alpha_x1:alpha_x2] image_region = result[y1:y2, x1:x2] # Convert image region to float image_f = image_region.astype(np.float32) # Create mask for pixels to process process_mask = alpha_region >= BlendModes.ALPHA_THRESHOLD # Expand alpha to 3 channels for broadcasting alpha_3c = alpha_region[:, :, np.newaxis] one_minus_alpha_3c = (1.0 - alpha_region)[:, :, np.newaxis] process_mask_3c = process_mask[:, :, np.newaxis] # Alpha blending formula: # result = alpha * logo + (1 - alpha) * original blended = alpha_3c * logo_value + one_minus_alpha_3c * image_f blended = np.clip(blended, 0.0, 255.0) # Only apply to pixels that need processing image_f = np.where(process_mask_3c, blended, image_f) # Convert back to 8-bit result[y1:y2, x1:x2] = image_f.astype(np.uint8) return result # ============================================================================ # WATERMARK REMOVER # ============================================================================ class GeminiWatermarkRemover: """ Removes Gemini watermarks using reverse alpha blending. Uses bg_48.png and bg_96.png alpha maps by default. """ # Watermark size configurations SMALL_SIZE = 48 LARGE_SIZE = 96 SMALL_MARGIN = 32 LARGE_MARGIN = 64 SIZE_THRESHOLD = 1024 # Default logo value (white) DEFAULT_LOGO_VALUE = 255.0 def __init__(self, alpha_map_small_path: Path = None, alpha_map_large_path: Path = None, logo_value: float = DEFAULT_LOGO_VALUE): """ Initialize the watermark remover. Args: alpha_map_small_path: Path to 48x48 alpha map (default: bg_48.png) alpha_map_large_path: Path to 96x96 alpha map (default: bg_96.png) logo_value: Logo pixel value (default 255 for white) """ # Use default paths if not specified self.alpha_map_small_path = alpha_map_small_path or DEFAULT_ALPHA_MAP_SMALL_PATH self.alpha_map_large_path = alpha_map_large_path or DEFAULT_ALPHA_MAP_LARGE_PATH self.logo_value = logo_value # Lazy-loaded alpha maps self._alpha_map_small = None self._alpha_map_large = None @property def alpha_map_small(self) -> np.ndarray: """Lazy-load small alpha map.""" if self._alpha_map_small is None: self._alpha_map_small = AlphaMapManager.load_alpha_map( Path(self.alpha_map_small_path), self.SMALL_SIZE ) return self._alpha_map_small @property def alpha_map_large(self) -> np.ndarray: """Lazy-load large alpha map.""" if self._alpha_map_large is None: self._alpha_map_large = AlphaMapManager.load_alpha_map( Path(self.alpha_map_large_path), self.LARGE_SIZE ) return self._alpha_map_large def detect_watermark_size(self, image: np.ndarray) -> Tuple[int, int]: """ Auto-detect watermark size based on image dimensions. Args: image: Input image as numpy array Returns: Tuple of (size, margin) for watermark """ height, width = image.shape[:2] if width <= self.SIZE_THRESHOLD or height <= self.SIZE_THRESHOLD: return self.SMALL_SIZE, self.SMALL_MARGIN else: return self.LARGE_SIZE, self.LARGE_MARGIN def get_watermark_position(self, image: np.ndarray, size: int, margin: int) -> Tuple[int, int]: """ Get the (x, y) position of the watermark top-left corner. Watermark is in bottom-right corner. Args: image: Input image size: Watermark size (48 or 96) margin: Margin from edge Returns: Tuple of (x, y) position """ height, width = image.shape[:2] x = width - margin - size y = height - margin - size return x, y def remove_watermark(self, image: np.ndarray, force_size: str = None, verbose: bool = False) -> np.ndarray: """ Remove watermark from image. Args: image: Input image as numpy array (RGB) force_size: Force 'small' (48x48) or 'large' (96x96) verbose: Print debug information Returns: Image with watermark removed """ # Ensure RGB if len(image.shape) == 2: # Grayscale - convert to RGB image = np.stack([image] * 3, axis=-1) elif len(image.shape) == 3 and image.shape[2] == 4: # RGBA - drop alpha image = image[:, :, :3] elif len(image.shape) != 3 or image.shape[2] != 3: raise ValueError("Image must be RGB or RGBA") # Detect or force watermark size if force_size == 'small': size, margin = self.SMALL_SIZE, self.SMALL_MARGIN elif force_size == 'large': size, margin = self.LARGE_SIZE, self.LARGE_MARGIN else: size, margin = self.detect_watermark_size(image) if verbose: print(f" Watermark size: {size}x{size}, margin: {margin}px") # Get watermark position x, y = self.get_watermark_position(image, size, margin) if verbose: print(f" Watermark position: ({x}, {y})") # Validate position if x < 0 or y < 0: if verbose: print(f" Warning: Image too small for {size}x{size} watermark") return image.copy() # Get appropriate alpha map if size == self.SMALL_SIZE: alpha_map = self.alpha_map_small else: alpha_map = self.alpha_map_large # Remove watermark result = BlendModes.remove_watermark_alpha_blend( image=image, alpha_map=alpha_map, position=(x, y), logo_value=self.logo_value ) return result def add_watermark(self, image: np.ndarray, force_size: str = None, verbose: bool = False) -> np.ndarray: """ Add watermark to image (for testing purposes). Args: image: Input image as numpy array (RGB) force_size: Force 'small' (48x48) or 'large' (96x96) verbose: Print debug information Returns: Image with watermark added """ # Ensure RGB if len(image.shape) == 2: image = np.stack([image] * 3, axis=-1) elif len(image.shape) == 3 and image.shape[2] == 4: image = image[:, :, :3] elif len(image.shape) != 3 or image.shape[2] != 3: raise ValueError("Image must be RGB or RGBA") # Detect or force watermark size if force_size == 'small': size, margin = self.SMALL_SIZE, self.SMALL_MARGIN elif force_size == 'large': size, margin = self.LARGE_SIZE, self.LARGE_MARGIN else: size, margin = self.detect_watermark_size(image) if verbose: print(f" Watermark size: {size}x{size}, margin: {margin}px") # Get watermark position x, y = self.get_watermark_position(image, size, margin) # Validate position if x < 0 or y < 0: if verbose: print(f" Warning: Image too small for {size}x{size} watermark") return image.copy() # Get appropriate alpha map if size == self.SMALL_SIZE: alpha_map = self.alpha_map_small else: alpha_map = self.alpha_map_large # Add watermark result = BlendModes.add_watermark_alpha_blend( image=image, alpha_map=alpha_map, position=(x, y), logo_value=self.logo_value ) return result # ============================================================================ # FILE OPERATIONS # ============================================================================ def load_image(path: str) -> np.ndarray: """Load image as RGB numpy array.""" img = Image.open(path).convert('RGB') return np.array(img) def save_image(image: np.ndarray, path: str, quality: int = 95): """Save numpy array as image.""" img = Image.fromarray(image) # Determine format from extension ext = Path(path).suffix.lower() if ext in ['.jpg', '.jpeg']: img.save(path, 'JPEG', quality=quality) elif ext == '.png': img.save(path, 'PNG') elif ext == '.webp': img.save(path, 'WEBP', quality=quality) elif ext == '.bmp': img.save(path, 'BMP') else: img.save(path) def process_single_file(input_path: str, output_path: str = None, remover: GeminiWatermarkRemover = None, force_size: str = None, verbose: bool = False, add_mode: bool = False): """ Process a single image file. Args: input_path: Path to input image output_path: Path for output (None = overwrite input) remover: Watermark remover instance force_size: Force 'small' or 'large' verbose: Print verbose output add_mode: Add watermark instead of removing """ if output_path is None: output_path = input_path if verbose: print(f"Processing: {input_path}") # Load image image = load_image(input_path) if verbose: print(f" Image size: {image.shape[1]}x{image.shape[0]}") # Process if add_mode: result = remover.add_watermark(image, force_size, verbose) action = "Added watermark" else: result = remover.remove_watermark(image, force_size, verbose) action = "Removed watermark" # Save result save_image(result, output_path) if verbose: print(f" {action}, saved to: {output_path}") def process_directory(input_dir: str, output_dir: str, remover: GeminiWatermarkRemover = None, force_size: str = None, verbose: bool = False, add_mode: bool = False): """ Process all images in a directory. Args: input_dir: Input directory path output_dir: Output directory path remover: Watermark remover instance force_size: Force 'small' or 'large' verbose: Print verbose output add_mode: Add watermark instead of removing """ input_path = Path(input_dir) output_path = Path(output_dir) # Create output directory output_path.mkdir(parents=True, exist_ok=True) # Supported formats formats = {'.jpg', '.jpeg', '.png', '.webp', '.bmp'} # Find all images images = [f for f in input_path.iterdir() if f.is_file() and f.suffix.lower() in formats] total = len(images) if verbose: print(f"Found {total} images to process") processed = 0 errors = 0 for idx, img_path in enumerate(images, 1): out_file = output_path / img_path.name try: if verbose: print(f"\n[{idx}/{total}] ", end="") process_single_file( str(img_path), str(out_file), remover, force_size, verbose, add_mode ) processed += 1 except Exception as e: print(f"Error processing {img_path.name}: {e}") errors += 1 print(f"\nCompleted: {processed} processed, {errors} errors") # ============================================================================ # CLI # ============================================================================ def print_banner(): """Print ASCII banner.""" banner = r""" ╔═══════════════════════════════════════════════════════════════════╗ ║ ║ ║ ██████╗ ███████╗███╗ ███╗██╗███╗ ██╗██╗ ║ ║ ██╔════╝ ██╔════╝████╗ ████║██║████╗ ██║██║ ║ ║ ██║ ███╗█████╗ ██╔████╔██║██║██╔██╗ ██║██║ ║ ║ ██║ ██║██╔══╝ ██║╚██╔╝██║██║██║╚██╗██║██║ ║ ║ ╚██████╔╝███████╗██║ ╚═╝ ██║██║██║ ╚████║██║ ║ ║ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ║ ║ ║ ║ Watermark Removal Tool (Python Edition) ║ ║ Reverse Alpha Blending Technology ║ ║ ║ ╚═══════════════════════════════════════════════════════════════════╝ """ print(banner) def check_alpha_maps(): """Check if alpha map files exist and print status.""" small_exists = DEFAULT_ALPHA_MAP_SMALL_PATH.exists() large_exists = DEFAULT_ALPHA_MAP_LARGE_PATH.exists() print("Alpha Map Status:") print(f" bg_48.png (48x48): {'✓ Found' if small_exists else '✗ Not found'} - {DEFAULT_ALPHA_MAP_SMALL_PATH}") print(f" bg_96.png (96x96): {'✓ Found' if large_exists else '✗ Not found'} - {DEFAULT_ALPHA_MAP_LARGE_PATH}") print() if not small_exists or not large_exists: print("WARNING: Missing alpha map files!") print("Please ensure bg_48.png and bg_96.png are in the same directory as this script.") print() return False return True def main(): """Main entry point.""" parser = argparse.ArgumentParser( description='Remove Gemini watermarks using reverse alpha blending', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=''' Examples: # Simple mode - edit in place python GWMRTool.py image.jpg # Specify output file python GWMRTool.py -i input.jpg -o output.jpg # Batch processing python GWMRTool.py -i ./input_folder/ -o ./output_folder/ # Force specific watermark size python GWMRTool.py -i image.jpg -o output.jpg --force-large # Add watermark (for testing) python GWMRTool.py -i clean.jpg -o watermarked.jpg --add Required Files: bg_48.png - 48x48 alpha map (for images <= 1024px) bg_96.png - 96x96 alpha map (for images > 1024px) Place these files in the same directory as this script. Mathematical Formula: Watermark Application: watermarked = α × logo + (1 - α) × original Reverse (Removal): original = (watermarked - α × logo) / (1 - α) ''' ) parser.add_argument('simple_input', nargs='?', help='Image file (simple mode - edits in place)') parser.add_argument('-i', '--input', help='Input image file or directory') parser.add_argument('-o', '--output', help='Output image file or directory') parser.add_argument('--force-small', action='store_true', help='Force 48x48 watermark size') parser.add_argument('--force-large', action='store_true', help='Force 96x96 watermark size') parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output') parser.add_argument('-q', '--quiet', action='store_true', help='Suppress all output except errors') parser.add_argument('-b', '--banner', action='store_true', help='Show ASCII banner') parser.add_argument('--add', action='store_true', help='Add watermark instead of removing (for testing)') parser.add_argument('--alpha-small', help='Path to custom 48x48 alpha map') parser.add_argument('--alpha-large', help='Path to custom 96x96 alpha map') parser.add_argument('--logo-value', type=float, default=255.0, help='Logo pixel value (default: 255 for white)') parser.add_argument('--check', action='store_true', help='Check alpha map files and exit') parser.add_argument('--version', action='version', version='Gemini Watermark Remover v1.0.0 (Python)') args = parser.parse_args() # Show banner if args.banner and not args.quiet: print_banner() # Check mode if args.check: check_alpha_maps() sys.exit(0) # Verbose mode (unless quiet) verbose = args.verbose and not args.quiet # Determine force size force_size = None if args.force_small: force_size = 'small' elif args.force_large: force_size = 'large' # Custom alpha map paths alpha_small_path = Path(args.alpha_small) if args.alpha_small else None alpha_large_path = Path(args.alpha_large) if args.alpha_large else None # Check default alpha maps exist (if not using custom) if alpha_small_path is None and not DEFAULT_ALPHA_MAP_SMALL_PATH.exists(): print(f"Error: Alpha map not found: {DEFAULT_ALPHA_MAP_SMALL_PATH}") print("Please ensure bg_48.png is in the same directory as this script.") print("Or specify a custom path with --alpha-small") sys.exit(1) if alpha_large_path is None and not DEFAULT_ALPHA_MAP_LARGE_PATH.exists(): print(f"Error: Alpha map not found: {DEFAULT_ALPHA_MAP_LARGE_PATH}") print("Please ensure bg_96.png is in the same directory as this script.") print("Or specify a custom path with --alpha-large") sys.exit(1) # Initialize remover try: remover = GeminiWatermarkRemover( alpha_map_small_path=alpha_small_path, alpha_map_large_path=alpha_large_path, logo_value=args.logo_value ) except Exception as e: print(f"Error initializing: {e}") sys.exit(1) # Determine input/output if args.simple_input: # Simple mode - edit in place if not Path(args.simple_input).exists(): print(f"Error: File not found: {args.simple_input}") sys.exit(1) try: process_single_file( args.simple_input, None, remover, force_size, verbose, args.add ) except Exception as e: print(f"Error: {e}") sys.exit(1) elif args.input: input_path = Path(args.input) if not input_path.exists(): print(f"Error: Input not found: {args.input}") sys.exit(1) if input_path.is_dir(): # Directory mode if not args.output: print("Error: Output directory required for batch processing (-o)") sys.exit(1) try: process_directory( args.input, args.output, remover, force_size, verbose, args.add ) except Exception as e: print(f"Error: {e}") sys.exit(1) else: # Single file mode output = args.output if args.output else args.input try: process_single_file( args.input, output, remover, force_size, verbose, args.add ) except Exception as e: print(f"Error: {e}") sys.exit(1) else: # No input provided - show help parser.print_help() print("\n" + "="*60) check_alpha_maps() sys.exit(0) if not args.quiet: print("Done!") # ============================================================================ # ENTRY POINT # ============================================================================ if __name__ == '__main__': main()