|
| 1 | +import threading |
| 2 | +import time |
| 3 | +from collections import OrderedDict |
| 4 | +from typing import List, Literal, Optional, Type, Union |
| 5 | +from uuid import uuid4 |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +from pydantic import ConfigDict, Field |
| 9 | + |
| 10 | +from inference.core.workflows.core_steps.visualizations.common.utils import str_to_color |
| 11 | +from inference.core.workflows.execution_engine.entities.base import ( |
| 12 | + Batch, |
| 13 | + ImageParentMetadata, |
| 14 | + OutputDefinition, |
| 15 | + WorkflowImageData, |
| 16 | +) |
| 17 | +from inference.core.workflows.execution_engine.entities.types import ( |
| 18 | + IMAGE_KIND, |
| 19 | + INTEGER_KIND, |
| 20 | + STRING_KIND, |
| 21 | + ImageInputField, |
| 22 | + Selector, |
| 23 | +) |
| 24 | +from inference.core.workflows.prototypes.block import ( |
| 25 | + BlockResult, |
| 26 | + WorkflowBlock, |
| 27 | + WorkflowBlockManifest, |
| 28 | +) |
| 29 | + |
| 30 | +LONG_DESCRIPTION = """ |
| 31 | +Generate a QR code image from a string input (typically a URL). |
| 32 | +
|
| 33 | +This block creates a QR code PNG image from the provided text input. It supports |
| 34 | +various customization options including size control, error correction levels, |
| 35 | +and visual styling. The generated QR code can be used in workflows where you need |
| 36 | +to create QR codes for URLs, text content, or other data that needs to be encoded. |
| 37 | +
|
| 38 | +The output is a PNG image that can be passed to other workflow blocks such as |
| 39 | +visualizers or image processing blocks. |
| 40 | +""" |
| 41 | + |
| 42 | + |
| 43 | +class BlockManifest(WorkflowBlockManifest): |
| 44 | + model_config = ConfigDict( |
| 45 | + json_schema_extra={ |
| 46 | + "name": "QR Code Generator", |
| 47 | + "version": "v1", |
| 48 | + "short_description": "Generate a QR code image from text input.", |
| 49 | + "long_description": LONG_DESCRIPTION, |
| 50 | + "license": "Apache-2.0", |
| 51 | + "block_type": "transformation", |
| 52 | + "ui_manifest": { |
| 53 | + "section": "transformation", |
| 54 | + "icon": "fas fa-qrcode", |
| 55 | + "blockPriority": 1, |
| 56 | + }, |
| 57 | + } |
| 58 | + ) |
| 59 | + type: Literal["roboflow_core/qr_code_generator@v1", "QRCodeGenerator"] |
| 60 | + |
| 61 | + # Main input - the text/URL to encode |
| 62 | + text: Union[str, Selector(kind=[STRING_KIND])] = Field( |
| 63 | + description="Text or URL to encode in the QR code", |
| 64 | + examples=["https://roboflow.com", "$inputs.url", "Hello World"], |
| 65 | + ) |
| 66 | + |
| 67 | + # Note: version=None and box_size=10 are hardcoded defaults (non-editable per spec) |
| 68 | + |
| 69 | + # Error correction level - Single-Select (no param linking per spec 9a) |
| 70 | + error_correct: Literal[ |
| 71 | + "Low (~7% word recovery / highest data capacity)", |
| 72 | + "Medium (~15% word recovery)", |
| 73 | + "Quartile (~25% word recovery)", |
| 74 | + "High (~30% word recovery / lowest data capacity)", |
| 75 | + ] = Field( |
| 76 | + default="Medium (~15% word recovery)", |
| 77 | + title="Error Correction", |
| 78 | + description="Increased error correction comes at the expense of data capacity (text length). Use higher error correction if the QR code is likely to be transformed or obscured, but use a lower error correction level if the URL is long and the QR code is clearly visible.", |
| 79 | + examples=[ |
| 80 | + "Low (~7% word recovery / highest data capacity)", |
| 81 | + "Medium (~15% word recovery)", |
| 82 | + "Quartile (~25% word recovery)", |
| 83 | + "High (~30% word recovery / lowest data capacity)", |
| 84 | + ], |
| 85 | + ) |
| 86 | + |
| 87 | + # Visual styling |
| 88 | + border: Union[int, Selector(kind=[INTEGER_KIND])] = Field( |
| 89 | + default=4, |
| 90 | + title="Border Width", |
| 91 | + description="Border thickness in modules (default: 4)", |
| 92 | + examples=[2, 4, 6, "$inputs.border"], |
| 93 | + ) |
| 94 | + fill_color: Union[str, Selector(kind=[STRING_KIND])] = Field( |
| 95 | + default="BLACK", |
| 96 | + title="Fill Color", |
| 97 | + description="QR code block color. Supports hex (#FF0000), rgb(255, 0, 0), standard names (BLACK, WHITE, RED, etc.), or CSS3 color names.", |
| 98 | + examples=["BLACK", "#000000", "rgb(0, 0, 0)", "$inputs.fill_color"], |
| 99 | + ) |
| 100 | + back_color: Union[str, Selector(kind=[STRING_KIND])] = Field( |
| 101 | + default="WHITE", |
| 102 | + title="Background Color", |
| 103 | + description="QR code background color. Supports hex (#FFFFFF), rgb(255, 255, 255), standard names (BLACK, WHITE, RED, etc.), or CSS3 color names.", |
| 104 | + examples=["WHITE", "#FFFFFF", "rgb(255, 255, 255)", "$inputs.back_color"], |
| 105 | + ) |
| 106 | + |
| 107 | + @classmethod |
| 108 | + def get_parameters_accepting_batches(cls) -> List[str]: |
| 109 | + return [] |
| 110 | + |
| 111 | + @classmethod |
| 112 | + def describe_outputs(cls) -> List[OutputDefinition]: |
| 113 | + return [ |
| 114 | + OutputDefinition(name="qr_code", kind=[IMAGE_KIND]), |
| 115 | + ] |
| 116 | + |
| 117 | + @classmethod |
| 118 | + def get_execution_engine_compatibility(cls) -> Optional[str]: |
| 119 | + return ">=1.3.0,<2.0.0" |
| 120 | + |
| 121 | + |
| 122 | +class QRCodeGeneratorBlockV1(WorkflowBlock): |
| 123 | + |
| 124 | + @classmethod |
| 125 | + def get_manifest(cls) -> Type[WorkflowBlockManifest]: |
| 126 | + return BlockManifest |
| 127 | + |
| 128 | + def run( |
| 129 | + self, |
| 130 | + text: str, |
| 131 | + error_correct: Literal[ |
| 132 | + "Low (~7% word recovery / highest data capacity)", |
| 133 | + "Medium (~15% word recovery)", |
| 134 | + "Quartile (~25% word recovery)", |
| 135 | + "High (~30% word recovery / lowest data capacity)", |
| 136 | + ] = "Medium (~15% word recovery)", |
| 137 | + border: int = 4, |
| 138 | + fill_color: str = "BLACK", |
| 139 | + back_color: str = "WHITE", |
| 140 | + ) -> BlockResult: |
| 141 | + |
| 142 | + qr_image = generate_qr_code( |
| 143 | + text=text, |
| 144 | + version=None, # Auto-sizing as per spec requirement 8a |
| 145 | + box_size=10, # Fixed default as per spec requirement 8b |
| 146 | + error_correct=error_correct, |
| 147 | + border=border, |
| 148 | + fill_color=fill_color, |
| 149 | + back_color=back_color, |
| 150 | + ) |
| 151 | + |
| 152 | + return {"qr_code": qr_image} |
| 153 | + |
| 154 | + |
| 155 | +def generate_qr_code( |
| 156 | + text: str, |
| 157 | + version: Optional[int] = None, |
| 158 | + box_size: int = 10, |
| 159 | + error_correct: str = "M", |
| 160 | + border: int = 4, |
| 161 | + fill_color: str = "BLACK", |
| 162 | + back_color: str = "WHITE", |
| 163 | +) -> WorkflowImageData: |
| 164 | + """Generate a QR code PNG image from text input.""" |
| 165 | + global _ERROR_LEVELS, _QR_CACHE |
| 166 | + |
| 167 | + # Check cache first |
| 168 | + cached_result = _QR_CACHE.get( |
| 169 | + text, version, box_size, error_correct, border, fill_color, back_color |
| 170 | + ) |
| 171 | + if cached_result is not None: |
| 172 | + return cached_result |
| 173 | + |
| 174 | + try: |
| 175 | + import qrcode |
| 176 | + except ImportError: |
| 177 | + raise ImportError( |
| 178 | + "qrcode library is required for QR code generation. " |
| 179 | + "Install it with: pip install qrcode" |
| 180 | + ) |
| 181 | + if _ERROR_LEVELS is None: |
| 182 | + _ERROR_LEVELS = _get_error_levels() |
| 183 | + |
| 184 | + # Parse colors using the common utility that handles hex, rgb, bgr, and standard names |
| 185 | + try: |
| 186 | + # Convert to supervision Color object, then to RGB tuple for qrcode library |
| 187 | + fill_sv_color = str_to_color(fill_color) |
| 188 | + fill = fill_sv_color.as_rgb() # Returns (R, G, B) tuple |
| 189 | + except (ValueError, AttributeError): |
| 190 | + # Fallback to original string if not a recognized format |
| 191 | + # This allows qrcode library to handle CSS3 color names directly |
| 192 | + fill = fill_color |
| 193 | + |
| 194 | + try: |
| 195 | + back_sv_color = str_to_color(back_color) |
| 196 | + back = back_sv_color.as_rgb() # Returns (R, G, B) tuple |
| 197 | + except (ValueError, AttributeError): |
| 198 | + # Fallback to original string if not a recognized format |
| 199 | + back = back_color |
| 200 | + |
| 201 | + error_level = _ERROR_LEVELS.get( |
| 202 | + error_correct.upper(), qrcode.constants.ERROR_CORRECT_M |
| 203 | + ) |
| 204 | + |
| 205 | + # Create QR code |
| 206 | + qr = qrcode.QRCode( |
| 207 | + version=version, |
| 208 | + error_correction=error_level, |
| 209 | + box_size=box_size, |
| 210 | + border=border, |
| 211 | + ) |
| 212 | + |
| 213 | + qr.add_data(text) |
| 214 | + qr.make(fit=(version is None)) |
| 215 | + |
| 216 | + # Generate image using default image factory |
| 217 | + img = qr.make_image( |
| 218 | + fill_color=fill, |
| 219 | + back_color=back, |
| 220 | + ).convert( |
| 221 | + "RGB" |
| 222 | + ) # Ensure always RGB |
| 223 | + |
| 224 | + # Direct conversion from PIL.Image to numpy array (much faster than encode/decode) |
| 225 | + numpy_image = np.array(img) |
| 226 | + |
| 227 | + # Convert from RGB (PIL format) to BGR (OpenCV/WorkflowImageData format) |
| 228 | + # PIL creates RGB images, but WorkflowImageData expects BGR format |
| 229 | + numpy_image = numpy_image[:, :, ::-1] # RGB -> BGR |
| 230 | + |
| 231 | + # Defensive: numpy_image should never be None; original code checks for None on OpenCV decode failure |
| 232 | + if numpy_image is None or numpy_image.size == 0: |
| 233 | + raise ValueError("Failed to generate QR code image") |
| 234 | + |
| 235 | + # Create WorkflowImageData |
| 236 | + parent_metadata = ImageParentMetadata(parent_id=f"qr_code.{uuid4()}") |
| 237 | + result = WorkflowImageData( |
| 238 | + parent_metadata=parent_metadata, |
| 239 | + numpy_image=numpy_image, |
| 240 | + ) |
| 241 | + |
| 242 | + # Store in cache |
| 243 | + _QR_CACHE.put( |
| 244 | + text, version, box_size, error_correct, border, fill_color, back_color, result |
| 245 | + ) |
| 246 | + |
| 247 | + return result |
| 248 | + |
| 249 | + |
| 250 | +class _QRCodeLRUCache: |
| 251 | + """LRU Cache with TTL for QR code generation results.""" |
| 252 | + |
| 253 | + def __init__(self, max_size: int = 100, ttl_seconds: int = 3600): |
| 254 | + self.max_size = max_size |
| 255 | + self.ttl_seconds = ttl_seconds |
| 256 | + self._cache = OrderedDict() |
| 257 | + self._lock = threading.RLock() |
| 258 | + |
| 259 | + def _make_key( |
| 260 | + self, |
| 261 | + text: str, |
| 262 | + version: Optional[int], |
| 263 | + box_size: int, |
| 264 | + error_correct: str, |
| 265 | + border: int, |
| 266 | + fill_color: str, |
| 267 | + back_color: str, |
| 268 | + ) -> str: |
| 269 | + """Create cache key from QR code parameters.""" |
| 270 | + return f"{text}|{version}|{box_size}|{error_correct}|{border}|{fill_color}|{back_color}" |
| 271 | + |
| 272 | + def _is_expired(self, timestamp: float) -> bool: |
| 273 | + """Check if cache entry is expired.""" |
| 274 | + return time.time() - timestamp > self.ttl_seconds |
| 275 | + |
| 276 | + def _cleanup_expired(self): |
| 277 | + """Remove expired entries from cache.""" |
| 278 | + current_time = time.time() |
| 279 | + expired_keys = [ |
| 280 | + key |
| 281 | + for key, (_, timestamp) in self._cache.items() |
| 282 | + if current_time - timestamp > self.ttl_seconds |
| 283 | + ] |
| 284 | + for key in expired_keys: |
| 285 | + del self._cache[key] |
| 286 | + |
| 287 | + def get( |
| 288 | + self, |
| 289 | + text: str, |
| 290 | + version: Optional[int], |
| 291 | + box_size: int, |
| 292 | + error_correct: str, |
| 293 | + border: int, |
| 294 | + fill_color: str, |
| 295 | + back_color: str, |
| 296 | + ) -> Optional: |
| 297 | + """Get cached QR code result if available and not expired.""" |
| 298 | + key = self._make_key( |
| 299 | + text, version, box_size, error_correct, border, fill_color, back_color |
| 300 | + ) |
| 301 | + |
| 302 | + with self._lock: |
| 303 | + if key in self._cache: |
| 304 | + result, timestamp = self._cache[key] |
| 305 | + if not self._is_expired(timestamp): |
| 306 | + # Move to end (most recently used) |
| 307 | + self._cache.move_to_end(key) |
| 308 | + return result |
| 309 | + else: |
| 310 | + # Remove expired entry |
| 311 | + del self._cache[key] |
| 312 | + |
| 313 | + return None |
| 314 | + |
| 315 | + def put( |
| 316 | + self, |
| 317 | + text: str, |
| 318 | + version: Optional[int], |
| 319 | + box_size: int, |
| 320 | + error_correct: str, |
| 321 | + border: int, |
| 322 | + fill_color: str, |
| 323 | + back_color: str, |
| 324 | + result, |
| 325 | + ) -> None: |
| 326 | + """Store QR code result in cache.""" |
| 327 | + key = self._make_key( |
| 328 | + text, version, box_size, error_correct, border, fill_color, back_color |
| 329 | + ) |
| 330 | + |
| 331 | + with self._lock: |
| 332 | + # Clean up expired entries periodically |
| 333 | + if len(self._cache) % 10 == 0: # Every 10th insertion |
| 334 | + self._cleanup_expired() |
| 335 | + |
| 336 | + # Remove oldest entries if at capacity |
| 337 | + while len(self._cache) >= self.max_size: |
| 338 | + self._cache.popitem(last=False) # Remove oldest (FIFO when at capacity) |
| 339 | + |
| 340 | + # Add new entry |
| 341 | + self._cache[key] = (result, time.time()) |
| 342 | + |
| 343 | + |
| 344 | +def _get_error_levels(): |
| 345 | + try: |
| 346 | + import qrcode |
| 347 | + |
| 348 | + C = qrcode.constants |
| 349 | + return { |
| 350 | + "LOW (~7% WORD RECOVERY / HIGHEST DATA CAPACITY)": C.ERROR_CORRECT_L, |
| 351 | + "MEDIUM (~15% WORD RECOVERY)": C.ERROR_CORRECT_M, |
| 352 | + "QUARTILE (~25% WORD RECOVERY)": C.ERROR_CORRECT_Q, |
| 353 | + "HIGH (~30% WORD RECOVERY / LOWEST DATA CAPACITY)": C.ERROR_CORRECT_H, |
| 354 | + "ERROR_CORRECT_L": C.ERROR_CORRECT_L, |
| 355 | + "ERROR_CORRECT_M": C.ERROR_CORRECT_M, |
| 356 | + "ERROR_CORRECT_Q": C.ERROR_CORRECT_Q, |
| 357 | + "ERROR_CORRECT_H": C.ERROR_CORRECT_H, |
| 358 | + "L": C.ERROR_CORRECT_L, |
| 359 | + "M": C.ERROR_CORRECT_M, |
| 360 | + "Q": C.ERROR_CORRECT_Q, |
| 361 | + "H": C.ERROR_CORRECT_H, |
| 362 | + } |
| 363 | + except ImportError: |
| 364 | + raise ImportError( |
| 365 | + "qrcode library is required for QR code generation. " |
| 366 | + "Install it with: pip install qrcode" |
| 367 | + ) |
| 368 | + |
| 369 | + |
| 370 | +_ERROR_LEVELS = None |
| 371 | +_QR_CACHE = _QRCodeLRUCache(max_size=100, ttl_seconds=3600) |
0 commit comments