Cat Feeder  1.0.0
The Cat feeder project
Loading...
Searching...
No Matches
image_reducer.py
Go to the documentation of this file.
1r"""
2# +==== BEGIN CatFeeder =================+
3# LOGO:
4# ..............(..../\
5# ...............)..(.')
6# ..............(../..)
7# ...............\‍(__)|
8# Inspired by Joan Stark
9# source https://www.asciiart.eu/
10# animals/cats
11# /STOP
12# PROJECT: CatFeeder
13# FILE: image_reducer.py
14# CREATION DATE: 05-01-2026
15# LAST Modified: 23:3:11 10-01-2026
16# DESCRIPTION:
17# This is the backend server in charge of making the actual website work.
18# /STOP
19# COPYRIGHT: (c) Cat Feeder
20# PURPOSE: This is the class in charge of doing the reducing work.
21# // AR
22# +==== END CatFeeder =================+
23"""
24
25import io
26from typing import TYPE_CHECKING, Optional, List, Union
27
28from fastapi import Response
29from scour import scour as _scour
30from PIL import Image, UnidentifiedImageError
31from display_tty import Disp, initialise_logger
32
33from . import image_reducer_constants as IR_CONST
34from . import image_reducer_error_class as IR_ERR
35
36from ..core import FinalClass
37from ..core.runtime_manager import RI, RuntimeManager
38from ..http_codes import HCI, HttpDataTypes
39
40if TYPE_CHECKING:
41 from ..server_header import ServerHeaders
42 from ..boilerplates.responses import BoilerplateResponses
43
44
45class ImageReducer(metaclass=FinalClass):
46 """Utility for validating, converting and compressing images.
47
48 Provides methods for opening, validating, converting, and saving images
49 in different formats. Supports raster formats (PNG, JPEG, WebP) and SVG
50 with optional compression.
51 """
52
53 # --------------------------------------------------------------------------
54 # STATIC CLASS VALUES
55 # --------------------------------------------------------------------------
56
57 # -------------- Initialise the logger globally in the class. --------------
58 disp: Disp = initialise_logger(__qualname__, False)
59
60 # --------------------------------------------------------------------------
61 # CONSTRUCTOR & DESTRUCTOR
62 # --------------------------------------------------------------------------
63
64 def __init__(self, error: int = 84, success: int = 0, debug: bool = False) -> None:
65 """Initialize the ImageReducer instance with dependencies.
66
67 Keyword Arguments:
68 debug: Enable debug logging when True. Defaults to False.
69 """
70 # ------------------------ The logging function ------------------------
71 self.disp.update_disp_debug(debug)
72 self.disp.log_debug("Initialising...")
73 self.error = error
74 self.success = success
75 self.runtime_manager: RuntimeManager = RI
76 self.boilerplate_response: "BoilerplateResponses" = self.runtime_manager.get(
77 "BoilerplateResponses")
78 self.server_header: "ServerHeaders" = self.runtime_manager.get(
79 "ServerHeaders")
80 self.disp.log_debug("Initialised")
81
83 self,
84 file_bytes: bytes,
85 allowed_formats: Optional[List[str]] = None,
86 output_format: Optional[str] = "WEBP",
87 max_dimension: Optional[int] = None,
88 *,
89 compress_svg: bool = False
90 ) -> bytes:
91 """Process an image with validation and conversion.
92
93 Convenience wrapper that calls reprocess_image_strict with the given
94 arguments. Validates image format, enforces maximum dimensions, and
95 converts to the specified output format using predefined compression settings.
96
97 Arguments:
98 file_bytes: Image data as raw bytes.
99 allowed_formats: Allowed input formats. Defaults to None.
100 output_format: Desired output format. Defaults to "WEBP".
101 max_dimension: Maximum allowed width/height in pixels. Defaults to None.
102 compress_svg: Whether to minify SVG files. Defaults to False.
103
104 Returns:
105 Processed image as bytes.
106 """
107
108 self.disp.log_debug(
109 f"output_format={output_format}, max_dimension={max_dimension}")
110 result = self.reprocess_image_strict(
111 file_bytes=file_bytes,
112 allowed_formats=allowed_formats,
113 output_format=output_format,
114 max_dimension=max_dimension,
115 compress_svg=compress_svg
116 )
117 self.disp.log_debug("completed")
118 return result
119
121 self,
122 file_bytes: bytes,
123 allowed_formats: Optional[List[str]] = None,
124 output_format: Optional[str] = "WEBP",
125 max_dimension: Optional[int] = None,
126 *,
127 compress_svg: bool = False,
128 title: str = "reprocess_image",
129 token: Optional[str] = None
130 ) -> Union[bytes, Response]:
131 """Process an image and return bytes or an error response.
132
133 Safely processes an image with comprehensive error handling. Returns
134 processed image bytes on success or an HTTP error response (400/413/415/500)
135 if validation fails. Uses predefined compression settings from constants.
136
137 Arguments:
138 file_bytes: The image data to process as raw bytes.
139 allowed_formats: Allowed input formats. Defaults to None.
140 output_format: Desired output format. Defaults to "WEBP".
141 max_dimension: Maximum allowed width/height in pixels. Defaults to None.
142 compress_svg: Whether to minify SVG files. Defaults to False.
143 title: Title for the HTTP response in case of error. Defaults to "reprocess_image".
144 token: Token to include in the HTTP response. Defaults to None.
145
146 Returns:
147 Processed image bytes on success, or an HTTP error Response.
148
149 Raises:
150 No exceptions raised. All errors result in HTTP responses.
151 """
152 try:
153 self.disp.log_debug("Reducing image (safe)")
154 data = self.reprocess_image_strict(
155 file_bytes=file_bytes,
156 allowed_formats=allowed_formats,
157 output_format=output_format,
158 max_dimension=max_dimension,
159 compress_svg=compress_svg
160 )
161 self.disp.log_info("Image_reduced")
162 self.disp.log_debug(
163 f"Image reduced (safe) result_bytes={len(data) if hasattr(data, '__len__') else 'unknown'}")
164 return data
165 except IR_ERR.ImageReducerInvalidImageFile as e:
166 self.disp.log_error(f"ImageReducerInvalidImageFile : '{str(e)}'")
167 body = self.boilerplate_response.build_response_body(
168 title=title,
169 message=str(e),
170 resp="invalid_image_file",
171 token=token,
172 error=True
173 )
174 return HCI.bad_request(content=body, content_type=HttpDataTypes.JSON, headers=self.server_header.for_json())
175 except IR_ERR.ImageReducerUnsupportedFormat as e:
176 self.disp.log_error(f"ImageReducerUnsupportedFormat : '{str(e)}'")
177 body = self.boilerplate_response.build_response_body(
178 title=title,
179 message=str(e),
180 resp="unsupported_format",
181 token=token,
182 error=True
183 )
184 return HCI.unsupported_media_type(content=body, content_type=HttpDataTypes.JSON, headers=self.server_header.for_json())
185 except IR_ERR.ImageReducerTooLarge as e:
186 self.disp.log_error(f"ImageReducerTooLarge : '{str(e)}'")
187 body = self.boilerplate_response.build_response_body(
188 title=title,
189 message=str(e),
190 resp="too_large",
191 token=token,
192 error=True
193 )
194 return HCI.payload_too_large(content=body, content_type=HttpDataTypes.JSON, headers=self.server_header.for_json())
195 except IR_ERR.ImageReducer as e:
196 self.disp.log_error(f"ImageReducer : '{str(e)}'")
197 body = self.boilerplate_response.build_response_body(
198 title=title,
199 message=str(e),
200 resp="image_reducer_error",
201 token=token,
202 error=True
203 )
204 return HCI.internal_server_error(content=body, content_type=HttpDataTypes.JSON, headers=self.server_header.for_json())
205 except ValueError as e:
206 self.disp.log_error(f"ValueError : '{str(e)}'")
207 body = self.boilerplate_response.build_response_body(
208 title=title,
209 message=str(e),
210 resp="value_error",
211 token=token,
212 error=True
213 )
214 return HCI.internal_server_error(content=body, content_type=HttpDataTypes.JSON, headers=self.server_header.for_json())
215
217 self,
218 file_bytes: bytes,
219 allowed_formats: Optional[List[str]] = None,
220 output_format: Optional[str] = "WEBP",
221 max_dimension: Optional[int] = None,
222 *,
223 compress_svg: bool = False
224 ) -> bytes:
225 """Process an image with validation and format conversion.
226
227 Detects file format, validates against allowed formats, enforces
228 dimension limits, converts to the desired output format using predefined
229 compression settings. For SVG files, optionally minifies them.
230 Does not rescale raster images.
231
232 Arguments:
233 file_bytes: Uploaded image as raw bytes.
234 allowed_formats: Allowed input formats. Defaults to None (uses ALLOWED_FORMATS).
235 output_format: Output format to use. Defaults to "WEBP".
236 max_dimension: Maximum allowed width/height in pixels. Defaults to None.
237 compress_svg: Whether to minify SVG files. Defaults to False.
238
239 Returns:
240 Processed image bytes.
241
242 Raises:
243 ImageReducerInvalidImageFile: If image bytes cannot be decoded.
244 ImageReducerUnsupportedFormat: If image format is not in allowed_formats.
245 ImageReducerTooLarge: If image dimensions exceed max_dimension.
246 """
247
248 self.disp.log_debug(
249 f"reprocess_image_strict start: output_format={output_format}, max_dimension={max_dimension}")
250
251 _file_format: IR_CONST.FileFormat = self.detect_file_format(
252 file_bytes)
253
254 if _file_format == IR_CONST.FileFormat.SVG:
255 if compress_svg:
256 return self._compress_svg(file_bytes)
257 return file_bytes
258
259 if allowed_formats is None:
260 allowed_formats = IR_CONST.ALLOWED_FORMATS
261 self.disp.log_debug(
262 f"Using default allowed_formats={allowed_formats}")
263
264 # Open and validate image
265 img: Image.Image = self._open_image(file_bytes)
266 self._validate_format(img, allowed_formats)
267 self._enforce_max_dimension(img, max_dimension)
268
269 # Ensure correct mode
270 img = self._ensure_mode(img)
271
272 # Build save parameters and save
273 fmt, save_params = self._build_save_params(output_format, img.format)
274 save_params["format"] = fmt
275 result = self._save_image(img, save_params)
276 self.disp.log_debug(
277 f"completed: result_bytes={len(result)}")
278 return result
279
280 def detect_file_format(self, file_bytes: bytes) -> IR_CONST.FileFormat:
281 """Detect file format from bytes content.
282
283 Uses heuristics to identify the image format:
284 1. Checks for SVG XML tags (lossless, checks first).
285 2. Uses Pillow to identify raster formats (PNG, JPEG, WebP).
286 3. Returns FileFormat.UNKNOWN if detection fails.
287
288 Arguments:
289 file_bytes: Raw file bytes to analyze.
290
291 Returns:
292 FileFormat enum indicating the detected format.
293 """
294 # Quick check for SVG textual content
295 if isinstance(file_bytes, (bytes, bytearray)):
296 head = file_bytes[:512].lstrip()
297 if b"<svg" in head.lower() or (head.startswith(b"<?xml") and b"<svg" in head.lower()):
298 self.disp.log_debug(
299 "detected SVG by content")
300 return IR_CONST.FileFormat.SVG
301 else:
302 self.disp.log_warning(
303 "file_bytes is not bytes-like")
304
305 # Try to use Pillow to identify raster formats
306 try:
307 img = Image.open(io.BytesIO(file_bytes))
308 fmt = (img.format or "").upper()
309 self.disp.log_debug(
310 f"Pillow detected format={fmt}")
311 if fmt == "PNG":
312 return IR_CONST.FileFormat.PNG
313 if fmt in ("JPEG", "JPG"):
314 return IR_CONST.FileFormat.JPEG
315 if fmt == "WEBP":
316 return IR_CONST.FileFormat.WEBP
317 except (UnidentifiedImageError, OSError) as e:
318 self.disp.log_debug(
319 f"Pillow failed to identify format: {e}")
320
321 return IR_CONST.FileFormat.UNKNOWN
322
323 def _compress_svg(self, file_bytes: bytes) -> bytes:
324 """Minify SVG content by removing comments and metadata.
325
326 Uses the scour library for safe, lossless SVG optimization. Removes
327 XML comments, metadata, and enables viewboxing. Gracefully degrades
328 if decoding fails and returns original bytes.
329
330 Arguments:
331 file_bytes: SVG file content as bytes (must be valid UTF-8).
332
333 Returns:
334 Minified SVG content as bytes.
335 """
336 self.disp.log_debug("_compress_svg: attempting svg minify")
337 if not isinstance(file_bytes, (bytes, bytearray)):
338 self.disp.log_warning(
339 "_compress_svg: input is not bytes-like, returning original")
340 return file_bytes
341
342 try:
343 text = file_bytes.decode("utf-8")
344 except UnicodeDecodeError:
345 text = file_bytes.decode("utf-8", errors="ignore")
346
347 # Use scour for safer, stronger optimisation (assumed available)
348 opts = _scour.sanitizeOptions()
349 opts.remove_metadata = True
350 opts.strip_comments = True
351 opts.enable_viewboxing = True
352 out_text = _scour.scourString(text, opts)
353 out_bytes = out_text.encode("utf-8")
354 self.disp.log_debug(
355 f"_compress_svg (scour): reduced from {len(file_bytes)} to {len(out_bytes)} bytes")
356 return out_bytes
357
358 def _open_image(self, file_bytes: bytes) -> Image.Image:
359 """Open image bytes and return a Pillow Image instance.
360
361 Arguments:
362 file_bytes: Raw image bytes to open.
363
364 Returns:
365 Pillow Image instance.
366
367 Raises:
368 ImageReducerInvalidImageFile: If bytes cannot be identified as a valid image.
369 """
370 self.disp.log_debug("attempting to open image bytes")
371 try:
372 img = Image.open(io.BytesIO(file_bytes))
373 self.disp.log_debug(
374 f"opened image format={img.format}, mode={img.mode}, size={getattr(img, 'size', None)}")
375 return img
376 except UnidentifiedImageError as e:
377 file = "incorrect byte format"
378 if not isinstance(file_bytes, bytes):
379 file = f"{type(file_bytes)}"
380 self.disp.log_error(f"{file}")
381 raise IR_ERR.ImageReducerInvalidImageFile(file, "bytes") from e
382
383 def _validate_format(self, img: Image.Image, allowed_formats: List[str]) -> None:
384 """Validate that image format is in the allowed formats list.
385
386 Arguments:
387 img: Pillow image to validate.
388 allowed_formats: List of allowed image format strings.
389
390 Raises:
391 ImageReducerUnsupportedFormat: If image format is not in allowed_formats.
392 """
393 self.disp.log_debug(
394 f"img.format={img.format}, allowed={allowed_formats}")
395 if img.format not in allowed_formats:
396 self.disp.log_warning(
397 f"unsupported format {img.format}")
398 raise IR_ERR.ImageReducerUnsupportedFormat(
399 img.format, allowed_formats
400 )
401
402 def _enforce_max_dimension(self, img: Image.Image, max_dimension: Optional[int]) -> None:
403 """Validate that image dimensions do not exceed the maximum allowed size.
404
405 Arguments:
406 img: Pillow image to check.
407 max_dimension: Maximum allowed width/height in pixels. If None, no check is performed.
408
409 Raises:
410 ImageReducerTooLarge: If image width or height exceeds max_dimension.
411 """
412 self.disp.log_debug(
413 f"img.size={img.width}x{img.height}, max_dimension={max_dimension}")
414 if max_dimension is not None:
415 if img.width > max_dimension or img.height > max_dimension:
416 self.disp.log_warning(
417 f"image too large {img.width}x{img.height} > {max_dimension}")
418 raise IR_ERR.ImageReducerTooLarge(
419 img.width, img.height, max_dimension
420 )
421
422 def _ensure_mode(self, img: Image.Image) -> Image.Image:
423 """Ensure image is in RGB or RGBA mode, converting if necessary.
424
425 Converts indexed (P), grayscale (L, LA), or other color modes to either
426 RGB (for opaque images) or RGBA (for images with alpha channel).
427
428 Arguments:
429 img: Pillow image to convert if necessary.
430
431 Returns:
432 Image in RGB or RGBA mode.
433 """
434 self.disp.log_debug(f"current mode={img.mode}")
435 if img.mode not in ("RGB", "RGBA"):
436 if "A" in img.mode:
437 target = "RGBA"
438 else:
439 target = "RGB"
440 self.disp.log_debug(
441 f"converting mode from {img.mode} to {target}")
442 return img.convert(target)
443 self.disp.log_debug("no conversion needed")
444 return img
445
446 def _build_save_params(self, output_format: Optional[str], img_format: Optional[str]) -> tuple:
447 """Build parameters for saving an image in the desired format.
448
449 Resolves the output format (from parameter, original format, or default),
450 and constructs a dictionary of save parameters using predefined compression
451 settings from COMPRESSION_QUALITY for the target format.
452
453 Arguments:
454 output_format: Desired output format (e.g., "WEBP", "PNG"). If None, uses original format or default.
455 img_format: Original image format from Pillow.
456
457 Returns:
458 Tuple of (format_string: str, save_params: dict).
459 """
460 self.disp.log_debug(
461 f"requested output_format={output_format}, img_format={img_format}")
462 # Determine output format
463 if not output_format:
464 if not img_format:
465 output_format = IR_CONST.DEFAULT_OUTPUT_FORMAT
466 else:
467 output_format = img_format
468
469 output_format = output_format.upper()
470 save_params: dict = {"optimize": True}
471
472 # Apply format-specific compression settings
473 of = output_format.lower()
474
475 if output_format in IR_CONST.ALLOWED_OUTPUT_FORMATS:
476 # For formats with quality settings, use the provided quality or default from COMPRESSION_QUALITY
477 if of in IR_CONST.COMPRESSION_QUALITY:
478 compression_value = IR_CONST.COMPRESSION_QUALITY[of]
479 # For lossy formats (JPEG, WebP, AVIF), use quality parameter
480 if of in ("jpeg", "jpg", "webp", "avif"):
481 save_params["quality"] = compression_value
482 # For PNG, use compression level (0-9)
483 elif of == "png":
484 save_params["compress_level"] = compression_value
485 # For GIF, use optimize flag
486 elif of == "gif":
487 save_params["optimize"] = bool(compression_value)
488
489 self.disp.log_debug(
490 f"resolved format={output_format}, save_params={save_params}")
491 return output_format, save_params
492
493 def _save_image(self, img: Image.Image, save_params: dict) -> bytes:
494 """Save a Pillow image to bytes using the specified parameters.
495
496 Arguments:
497 img: Pillow image to save.
498 save_params: Dictionary of parameters to pass to Image.save() (e.g., format, quality, optimize).
499
500 Returns:
501 Saved image content as raw bytes.
502 """
503 self.disp.log_debug(f"saving with params={save_params}")
504 data = io.BytesIO()
505 img.save(data, **save_params)
506 out = data.getvalue()
507 self.disp.log_debug(f"saved {len(out)} bytes")
508 return out
509
510 def test_images(self) -> None:
511 """Test image reduction by processing a sample file.
512
513 Loads "admin_upload.png", processes it to WebP format with quality 90
514 and a maximum dimension of 1024px, then saves the result to "processed.webp".
515 Useful for validating the reduction pipeline end-to-end.
516 """
517 with open("admin_upload.png", "rb") as f:
518 original_bytes: bytes = f.read()
519
520 processed_bytes: bytes = self.reprocess_image_strict(
521 file_bytes=original_bytes,
522 output_format="WEBP",
523 max_dimension=1024
524 )
525
526 with open("processed.webp", "wb") as f:
527 f.write(processed_bytes)
Union[bytes, Response] reprocess_image_strict_safe(self, bytes file_bytes, Optional[List[str]] allowed_formats=None, Optional[str] output_format="WEBP", Optional[int] max_dimension=None, *, bool compress_svg=False, str title="reprocess_image", Optional[str] token=None)
None _enforce_max_dimension(self, Image.Image img, Optional[int] max_dimension)
None __init__(self, int error=84, int success=0, bool debug=False)
bytes __call__(self, bytes file_bytes, Optional[List[str]] allowed_formats=None, Optional[str] output_format="WEBP", Optional[int] max_dimension=None, *, bool compress_svg=False)
bytes _save_image(self, Image.Image img, dict save_params)
tuple _build_save_params(self, Optional[str] output_format, Optional[str] img_format)
bytes reprocess_image_strict(self, bytes file_bytes, Optional[List[str]] allowed_formats=None, Optional[str] output_format="WEBP", Optional[int] max_dimension=None, *, bool compress_svg=False)
IR_CONST.FileFormat detect_file_format(self, bytes file_bytes)
None _validate_format(self, Image.Image img, List[str] allowed_formats)