From cfd1d8fb66d6eaa6b1cecd81d346290c977bc4c9 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Thu, 1 Jan 2026 22:52:06 -0500 Subject: [PATCH] Snazzy ui updates. --- src/stegasoo/__init__.py | 3 + src/stegasoo/constants.py | 2 + src/stegasoo/qr_utils.py | 123 +++++++++++++++++++++++++++++++++++++- 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/stegasoo/__init__.py b/src/stegasoo/__init__.py index 24dd291..80ff686 100644 --- a/src/stegasoo/__init__.py +++ b/src/stegasoo/__init__.py @@ -42,12 +42,14 @@ try: from .qr_utils import ( generate_qr_code, extract_key_from_qr, + detect_and_crop_qr, ) HAS_QR_UTILS = True except ImportError: HAS_QR_UTILS = False generate_qr_code = None extract_key_from_qr = None + detect_and_crop_qr = None # Validation from .validation import ( @@ -166,6 +168,7 @@ __all__ = [ # QR utilities "generate_qr_code", "extract_key_from_qr", + "detect_and_crop_qr", "HAS_QR_UTILS", # Validation diff --git a/src/stegasoo/constants.py b/src/stegasoo/constants.py index b8f7c98..cc152ca 100644 --- a/src/stegasoo/constants.py +++ b/src/stegasoo/constants.py @@ -109,6 +109,8 @@ THUMBNAIL_QUALITY = 85 # QR Code limits QR_MAX_BINARY = 2900 # Safe limit for binary data in QR +QR_CROP_PADDING_PERCENT = 0.1 # Default padding when cropping QR codes +QR_CROP_MIN_PADDING_PX = 10 # Minimum padding in pixels # ============================================================================ # FILE TYPES diff --git a/src/stegasoo/qr_utils.py b/src/stegasoo/qr_utils.py index 0faf7bf..95fdb08 100644 --- a/src/stegasoo/qr_utils.py +++ b/src/stegasoo/qr_utils.py @@ -34,9 +34,15 @@ except ImportError: HAS_QRCODE_READ = False +from .constants import ( + QR_MAX_BINARY, + QR_CROP_PADDING_PERCENT, + QR_CROP_MIN_PADDING_PX, +) + + # Constants COMPRESSION_PREFIX = "STEGASOO-Z:" -QR_MAX_BINARY = 2900 # Safe limit for binary data in QR def compress_data(data: str) -> str: @@ -383,6 +389,121 @@ def extract_key_from_qr_file(filepath: str) -> Optional[str]: return extract_key_from_qr(f.read()) +def detect_and_crop_qr( + image_data: bytes, + padding_percent: float = QR_CROP_PADDING_PERCENT, + min_padding_px: int = QR_CROP_MIN_PADDING_PX +) -> Optional[bytes]: + """ + Detect QR code in image and crop to it, handling rotation. + + Uses the QR code's corner coordinates to compute an axis-aligned + bounding box, then adds padding to ensure rotated QR codes aren't clipped. + + Args: + image_data: Input image bytes (PNG, JPG, etc.) + padding_percent: Padding as fraction of QR size (default 10%) + min_padding_px: Minimum padding in pixels (default 10) + + Returns: + Cropped PNG image bytes, or None if no QR code found + + Raises: + RuntimeError: If pyzbar library not available + """ + if not HAS_QRCODE_READ: + raise RuntimeError( + "pyzbar library not installed. Run: pip install pyzbar\n" + "Also requires system library: sudo apt-get install libzbar0" + ) + + try: + img: Image.Image = Image.open(io.BytesIO(image_data)) + original_mode = img.mode + + # Convert for pyzbar detection + if img.mode not in ('RGB', 'L'): + detect_img = img.convert('RGB') + else: + detect_img = img + + # Decode QR codes to get corner positions + decoded = pyzbar_decode(detect_img, symbols=[ZBarSymbol.QRCODE]) + + if not decoded: + return None + + # Get the polygon corners of the first QR code + # pyzbar returns a Polygon with Point objects (x, y attributes) + polygon = decoded[0].polygon + + if len(polygon) < 4: + # Fallback to rect if polygon not available + rect = decoded[0].rect + min_x, min_y = rect.left, rect.top + max_x, max_y = rect.left + rect.width, rect.top + rect.height + else: + # Extract corner coordinates - handles any rotation + xs = [p.x for p in polygon] + ys = [p.y for p in polygon] + min_x, max_x = min(xs), max(xs) + min_y, max_y = min(ys), max(ys) + + # Calculate QR dimensions and padding + qr_width = max_x - min_x + qr_height = max_y - min_y + + # Use larger dimension for padding calculation (handles rotation) + qr_size = max(qr_width, qr_height) + padding = max(int(qr_size * padding_percent), min_padding_px) + + # Calculate crop box with padding, clamped to image bounds + img_width, img_height = img.size + crop_left = max(0, min_x - padding) + crop_top = max(0, min_y - padding) + crop_right = min(img_width, max_x + padding) + crop_bottom = min(img_height, max_y + padding) + + # Crop the original image (preserves original mode/quality) + cropped = img.crop((crop_left, crop_top, crop_right, crop_bottom)) + + # Convert to PNG bytes + buf = io.BytesIO() + # Preserve transparency if present + if original_mode in ('RGBA', 'LA', 'P'): + cropped.save(buf, format='PNG') + else: + cropped.save(buf, format='PNG') + buf.seek(0) + return buf.getvalue() + + except Exception as e: + # Log for debugging but return None for clean API + import sys + print(f"QR crop error: {e}", file=sys.stderr) + return None + + +def detect_and_crop_qr_file( + filepath: str, + padding_percent: float = QR_CROP_PADDING_PERCENT, + min_padding_px: int = QR_CROP_MIN_PADDING_PX +) -> Optional[bytes]: + """ + Detect QR code in image file and crop to it. + + Args: + filepath: Path to image file + padding_percent: Padding as fraction of QR size (default 10%) + min_padding_px: Minimum padding in pixels (default 10) + + Returns: + Cropped PNG image bytes, or None if no QR code found + """ + with open(filepath, 'rb') as f: + return detect_and_crop_qr(f.read(), padding_percent, min_padding_px) + + def has_qr_write() -> bool: """Check if QR code writing is available.""" return HAS_QRCODE_WRITE