cloudflare-images-migrator 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
src/quality.py ADDED
@@ -0,0 +1,558 @@
1
+ """
2
+ Premium quality enhancement module for Cloudflare Images Migration Tool
3
+ """
4
+
5
+ import io
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Dict, List, Optional, Tuple, Any
9
+ from PIL import Image, ImageOps, ImageFilter, ImageEnhance
10
+ import requests
11
+ from concurrent.futures import ThreadPoolExecutor, as_completed
12
+
13
+
14
+ class QualityOptimizer:
15
+ """Premium quality optimizer for images before upload."""
16
+
17
+ def __init__(self, config, logger=None):
18
+ self.config = config
19
+ self.logger = logger
20
+
21
+ # Quality settings
22
+ self.optimization_levels = {
23
+ 'conservative': {'quality': 95, 'optimize': True, 'progressive': True},
24
+ 'balanced': {'quality': 85, 'optimize': True, 'progressive': True},
25
+ 'aggressive': {'quality': 75, 'optimize': True, 'progressive': True}
26
+ }
27
+
28
+ self.default_level = getattr(config, 'optimization_level', 'balanced')
29
+
30
+ # Advanced settings
31
+ self.enable_lossless_optimization = True
32
+ self.enable_format_conversion = True
33
+ self.enable_responsive_variants = True
34
+ self.max_dimension_optimization = 2048
35
+
36
+ # Quality thresholds
37
+ self.quality_thresholds = {
38
+ 'file_size_mb': 5.0,
39
+ 'dimensions': (4000, 4000),
40
+ 'compression_ratio': 0.7
41
+ }
42
+
43
+ def analyze_image_quality(self, file_path: Path) -> Dict[str, Any]:
44
+ """
45
+ Comprehensive image quality analysis.
46
+
47
+ Returns:
48
+ Dict with quality metrics and recommendations
49
+ """
50
+ analysis = {
51
+ 'quality_score': 0.0,
52
+ 'metrics': {},
53
+ 'recommendations': [],
54
+ 'optimization_potential': {},
55
+ 'estimated_savings': {}
56
+ }
57
+
58
+ try:
59
+ with Image.open(file_path) as img:
60
+ original_size = file_path.stat().st_size
61
+
62
+ # Basic metrics
63
+ analysis['metrics'] = {
64
+ 'format': img.format,
65
+ 'mode': img.mode,
66
+ 'size': img.size,
67
+ 'file_size_bytes': original_size,
68
+ 'file_size_mb': original_size / (1024 * 1024),
69
+ 'compression_ratio': self._calculate_compression_ratio(img, original_size),
70
+ 'color_depth': self._analyze_color_depth(img),
71
+ 'has_transparency': self._has_transparency(img),
72
+ 'estimated_quality': self._estimate_jpeg_quality(img, file_path)
73
+ }
74
+
75
+ # Quality scoring
76
+ analysis['quality_score'] = self._calculate_quality_score(analysis['metrics'])
77
+
78
+ # Optimization analysis
79
+ analysis['optimization_potential'] = self._analyze_optimization_potential(img, file_path)
80
+
81
+ # Size reduction estimates
82
+ analysis['estimated_savings'] = self._estimate_savings(img, file_path)
83
+
84
+ # Recommendations
85
+ analysis['recommendations'] = self._generate_quality_recommendations(analysis)
86
+
87
+ except Exception as e:
88
+ analysis['error'] = f"Quality analysis failed: {str(e)}"
89
+ if self.logger:
90
+ self.logger.error(f"Quality analysis error for {file_path}: {str(e)}")
91
+
92
+ return analysis
93
+
94
+ def optimize_image(self, file_path: Path, output_path: Optional[Path] = None,
95
+ optimization_level: str = None) -> Dict[str, Any]:
96
+ """
97
+ Optimize image with premium quality settings.
98
+
99
+ Returns:
100
+ Dict with optimization results
101
+ """
102
+ if optimization_level is None:
103
+ optimization_level = self.default_level
104
+
105
+ result = {
106
+ 'success': False,
107
+ 'original_size': 0,
108
+ 'optimized_size': 0,
109
+ 'size_reduction': 0.0,
110
+ 'quality_maintained': True,
111
+ 'optimizations_applied': [],
112
+ 'output_path': output_path or file_path
113
+ }
114
+
115
+ try:
116
+ original_size = file_path.stat().st_size
117
+ result['original_size'] = original_size
118
+
119
+ with Image.open(file_path) as img:
120
+ optimized_img = self._apply_optimizations(img, file_path, optimization_level)
121
+
122
+ # Save optimized image
123
+ save_path = output_path or file_path
124
+ self._save_optimized_image(optimized_img, save_path, img.format, optimization_level)
125
+
126
+ # Calculate results
127
+ optimized_size = save_path.stat().st_size
128
+ result['optimized_size'] = optimized_size
129
+ result['size_reduction'] = (original_size - optimized_size) / original_size
130
+ result['success'] = True
131
+
132
+ if self.logger:
133
+ reduction_pct = result['size_reduction'] * 100
134
+ self.logger.info(f"Optimized {file_path.name}: {reduction_pct:.1f}% size reduction")
135
+
136
+ except Exception as e:
137
+ result['error'] = f"Optimization failed: {str(e)}"
138
+ if self.logger:
139
+ self.logger.error(f"Optimization error for {file_path}: {str(e)}")
140
+
141
+ return result
142
+
143
+ def create_responsive_variants(self, file_path: Path,
144
+ sizes: List[Tuple[int, int]] = None) -> List[Dict[str, Any]]:
145
+ """
146
+ Create responsive image variants for different screen sizes.
147
+
148
+ Returns:
149
+ List of variant creation results
150
+ """
151
+ if sizes is None:
152
+ sizes = [(320, 240), (768, 576), (1024, 768), (1920, 1080)]
153
+
154
+ variants = []
155
+
156
+ try:
157
+ with Image.open(file_path) as img:
158
+ original_width, original_height = img.size
159
+
160
+ for target_width, target_height in sizes:
161
+ # Skip if target is larger than original
162
+ if target_width > original_width or target_height > original_height:
163
+ continue
164
+
165
+ variant_result = self._create_variant(
166
+ img, file_path, target_width, target_height
167
+ )
168
+ variants.append(variant_result)
169
+
170
+ except Exception as e:
171
+ if self.logger:
172
+ self.logger.error(f"Variant creation error for {file_path}: {str(e)}")
173
+
174
+ return variants
175
+
176
+ def enhance_image_quality(self, file_path: Path) -> Dict[str, Any]:
177
+ """
178
+ Apply AI-powered quality enhancements.
179
+
180
+ Returns:
181
+ Dict with enhancement results
182
+ """
183
+ result = {
184
+ 'success': False,
185
+ 'enhancements_applied': [],
186
+ 'quality_improvement': 0.0
187
+ }
188
+
189
+ try:
190
+ with Image.open(file_path) as img:
191
+ enhanced_img = img.copy()
192
+
193
+ # Auto-level adjustment
194
+ if self._needs_level_adjustment(img):
195
+ enhanced_img = ImageOps.autocontrast(enhanced_img)
196
+ result['enhancements_applied'].append('auto_contrast')
197
+
198
+ # Sharpening for web display
199
+ if self._needs_sharpening(img):
200
+ enhanced_img = enhanced_img.filter(ImageFilter.UnsharpMask(
201
+ radius=1.0, percent=120, threshold=1
202
+ ))
203
+ result['enhancements_applied'].append('unsharp_mask')
204
+
205
+ # Color enhancement
206
+ if self._needs_color_enhancement(img):
207
+ enhancer = ImageEnhance.Color(enhanced_img)
208
+ enhanced_img = enhancer.enhance(1.1)
209
+ result['enhancements_applied'].append('color_enhancement')
210
+
211
+ # Save enhanced image
212
+ enhanced_img.save(file_path, quality=95, optimize=True)
213
+ result['success'] = True
214
+
215
+ except Exception as e:
216
+ result['error'] = f"Enhancement failed: {str(e)}"
217
+ if self.logger:
218
+ self.logger.error(f"Enhancement error for {file_path}: {str(e)}")
219
+
220
+ return result
221
+
222
+ def _apply_optimizations(self, img: Image.Image, file_path: Path,
223
+ optimization_level: str) -> Image.Image:
224
+ """Apply comprehensive optimizations to image."""
225
+ optimized = img.copy()
226
+ settings = self.optimization_levels[optimization_level]
227
+
228
+ # Format-specific optimizations
229
+ if img.format == 'PNG':
230
+ # PNG optimizations
231
+ if not self._has_transparency(img) and self.enable_format_conversion:
232
+ # Convert to RGB if no transparency needed
233
+ if img.mode in ('RGBA', 'LA'):
234
+ background = Image.new('RGB', img.size, (255, 255, 255))
235
+ background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
236
+ optimized = background
237
+
238
+ elif img.format in ('JPEG', 'JPG'):
239
+ # JPEG optimizations
240
+ if img.mode != 'RGB':
241
+ optimized = optimized.convert('RGB')
242
+
243
+ # Dimension optimization
244
+ if max(optimized.size) > self.max_dimension_optimization:
245
+ ratio = self.max_dimension_optimization / max(optimized.size)
246
+ new_size = tuple(int(dim * ratio) for dim in optimized.size)
247
+ optimized = optimized.resize(new_size, Image.Resampling.LANCZOS)
248
+
249
+ return optimized
250
+
251
+ def _save_optimized_image(self, img: Image.Image, save_path: Path,
252
+ original_format: str, optimization_level: str):
253
+ """Save image with optimal settings."""
254
+ settings = self.optimization_levels[optimization_level]
255
+
256
+ save_kwargs = {
257
+ 'optimize': settings['optimize'],
258
+ 'quality': settings['quality']
259
+ }
260
+
261
+ if original_format == 'JPEG':
262
+ save_kwargs.update({
263
+ 'progressive': settings['progressive'],
264
+ 'subsampling': 0 # Best quality subsampling
265
+ })
266
+ elif original_format == 'PNG':
267
+ save_kwargs.update({
268
+ 'compress_level': 9, # Maximum compression
269
+ 'optimize': True
270
+ })
271
+
272
+ img.save(save_path, **save_kwargs)
273
+
274
+ def _calculate_compression_ratio(self, img: Image.Image, file_size: int) -> float:
275
+ """Calculate current compression ratio."""
276
+ try:
277
+ # Estimate uncompressed size (width * height * channels * bytes_per_channel)
278
+ channels = len(img.getbands())
279
+ uncompressed_size = img.width * img.height * channels
280
+ return file_size / uncompressed_size if uncompressed_size > 0 else 1.0
281
+ except:
282
+ return 1.0
283
+
284
+ def _analyze_color_depth(self, img: Image.Image) -> Dict[str, Any]:
285
+ """Analyze color depth and usage."""
286
+ colors = img.getcolors(maxcolors=256*256*256)
287
+
288
+ return {
289
+ 'unique_colors': len(colors) if colors else 'many',
290
+ 'mode': img.mode,
291
+ 'has_palette': hasattr(img, 'palette') and img.palette is not None
292
+ }
293
+
294
+ def _has_transparency(self, img: Image.Image) -> bool:
295
+ """Check if image has transparency."""
296
+ return img.mode in ('RGBA', 'LA') or 'transparency' in img.info
297
+
298
+ def _estimate_jpeg_quality(self, img: Image.Image, file_path: Path) -> Optional[int]:
299
+ """Estimate JPEG quality level."""
300
+ if img.format != 'JPEG':
301
+ return None
302
+
303
+ try:
304
+ # This is a simplified estimation
305
+ # In practice, you'd use more sophisticated methods
306
+ file_size = file_path.stat().st_size
307
+ pixels = img.width * img.height
308
+ bytes_per_pixel = file_size / pixels
309
+
310
+ # Rough quality estimation based on bytes per pixel
311
+ if bytes_per_pixel > 2.0:
312
+ return 95
313
+ elif bytes_per_pixel > 1.5:
314
+ return 85
315
+ elif bytes_per_pixel > 1.0:
316
+ return 75
317
+ elif bytes_per_pixel > 0.5:
318
+ return 60
319
+ else:
320
+ return 40
321
+ except:
322
+ return None
323
+
324
+ def _calculate_quality_score(self, metrics: Dict) -> float:
325
+ """Calculate overall quality score (0-100)."""
326
+ score = 100.0
327
+
328
+ # File size penalty
329
+ if metrics['file_size_mb'] > self.quality_thresholds['file_size_mb']:
330
+ score -= 20
331
+
332
+ # Dimension efficiency
333
+ max_dimension = max(metrics['size'])
334
+ if max_dimension > self.quality_thresholds['dimensions'][0]:
335
+ score -= 15
336
+
337
+ # Compression efficiency
338
+ if metrics['compression_ratio'] > self.quality_thresholds['compression_ratio']:
339
+ score -= 10
340
+
341
+ # Format efficiency
342
+ if metrics['format'] == 'BMP':
343
+ score -= 30 # Uncompressed format
344
+ elif metrics['format'] == 'PNG' and not metrics['has_transparency']:
345
+ score -= 5 # Could potentially be JPEG
346
+
347
+ return max(0.0, min(100.0, score))
348
+
349
+ def _analyze_optimization_potential(self, img: Image.Image, file_path: Path) -> Dict:
350
+ """Analyze potential for optimization."""
351
+ potential = {
352
+ 'format_conversion': False,
353
+ 'dimension_reduction': False,
354
+ 'quality_reduction': False,
355
+ 'metadata_removal': False
356
+ }
357
+
358
+ # Format conversion potential
359
+ if img.format == 'PNG' and not self._has_transparency(img):
360
+ potential['format_conversion'] = True
361
+
362
+ # Dimension reduction potential
363
+ if max(img.size) > self.max_dimension_optimization:
364
+ potential['dimension_reduction'] = True
365
+
366
+ # Quality reduction potential
367
+ if img.format == 'JPEG':
368
+ estimated_quality = self._estimate_jpeg_quality(img, file_path)
369
+ if estimated_quality and estimated_quality > 85:
370
+ potential['quality_reduction'] = True
371
+
372
+ # Metadata removal potential
373
+ if hasattr(img, '_getexif') and img._getexif():
374
+ potential['metadata_removal'] = True
375
+
376
+ return potential
377
+
378
+ def _estimate_savings(self, img: Image.Image, file_path: Path) -> Dict:
379
+ """Estimate potential file size savings."""
380
+ current_size = file_path.stat().st_size
381
+
382
+ savings = {
383
+ 'format_conversion': 0,
384
+ 'dimension_reduction': 0,
385
+ 'quality_optimization': 0,
386
+ 'total_estimated': 0
387
+ }
388
+
389
+ # Format conversion savings (PNG to JPEG)
390
+ if img.format == 'PNG' and not self._has_transparency(img):
391
+ savings['format_conversion'] = current_size * 0.3 # ~30% savings
392
+
393
+ # Dimension reduction savings
394
+ if max(img.size) > self.max_dimension_optimization:
395
+ ratio = self.max_dimension_optimization / max(img.size)
396
+ savings['dimension_reduction'] = current_size * (1 - ratio * ratio)
397
+
398
+ # Quality optimization savings
399
+ if img.format == 'JPEG':
400
+ estimated_quality = self._estimate_jpeg_quality(img, file_path)
401
+ if estimated_quality and estimated_quality > 85:
402
+ savings['quality_optimization'] = current_size * 0.15 # ~15% savings
403
+
404
+ savings['total_estimated'] = sum(savings.values()) - savings['total_estimated']
405
+
406
+ return savings
407
+
408
+ def _generate_quality_recommendations(self, analysis: Dict) -> List[str]:
409
+ """Generate quality improvement recommendations."""
410
+ recommendations = []
411
+ metrics = analysis['metrics']
412
+ potential = analysis.get('optimization_potential', {})
413
+
414
+ if potential.get('format_conversion'):
415
+ recommendations.append('Convert PNG to JPEG for better compression (no transparency needed)')
416
+
417
+ if potential.get('dimension_reduction'):
418
+ recommendations.append(f'Reduce dimensions to max {self.max_dimension_optimization}px for web usage')
419
+
420
+ if potential.get('quality_reduction'):
421
+ recommendations.append('Reduce JPEG quality to 85% for optimal web performance')
422
+
423
+ if metrics['file_size_mb'] > 2.0:
424
+ recommendations.append('Consider aggressive optimization for files over 2MB')
425
+
426
+ if analysis['quality_score'] < 70:
427
+ recommendations.append('Multiple optimizations recommended for significant improvements')
428
+
429
+ return recommendations
430
+
431
+ def _create_variant(self, img: Image.Image, file_path: Path,
432
+ target_width: int, target_height: int) -> Dict:
433
+ """Create a responsive variant."""
434
+ result = {
435
+ 'size': (target_width, target_height),
436
+ 'success': False,
437
+ 'file_path': None
438
+ }
439
+
440
+ try:
441
+ # Calculate aspect ratio preserving resize
442
+ img_ratio = img.width / img.height
443
+ target_ratio = target_width / target_height
444
+
445
+ if img_ratio > target_ratio:
446
+ # Image is wider, fit to width
447
+ new_height = int(target_width / img_ratio)
448
+ new_size = (target_width, new_height)
449
+ else:
450
+ # Image is taller, fit to height
451
+ new_width = int(target_height * img_ratio)
452
+ new_size = (new_width, target_height)
453
+
454
+ # Resize with high quality
455
+ variant = img.resize(new_size, Image.Resampling.LANCZOS)
456
+
457
+ # Generate variant filename
458
+ stem = file_path.stem
459
+ suffix = file_path.suffix
460
+ variant_path = file_path.parent / f"{stem}_{target_width}x{target_height}{suffix}"
461
+
462
+ # Save variant
463
+ self._save_optimized_image(variant, variant_path, img.format, 'balanced')
464
+
465
+ result['success'] = True
466
+ result['file_path'] = variant_path
467
+
468
+ except Exception as e:
469
+ result['error'] = str(e)
470
+
471
+ return result
472
+
473
+ def _needs_level_adjustment(self, img: Image.Image) -> bool:
474
+ """Check if image needs level adjustment."""
475
+ # Simple histogram analysis
476
+ if img.mode == 'RGB':
477
+ histogram = img.histogram()
478
+ # Check if histogram is heavily skewed
479
+ total_pixels = img.width * img.height
480
+ dark_pixels = sum(histogram[:64]) # First quarter of histogram
481
+ return (dark_pixels / total_pixels) > 0.7
482
+ return False
483
+
484
+ def _needs_sharpening(self, img: Image.Image) -> bool:
485
+ """Check if image needs sharpening."""
486
+ # This is a simplified check
487
+ # In practice, you'd analyze edge detection or Laplacian variance
488
+ return max(img.size) > 500 # Apply sharpening to larger images
489
+
490
+ def _needs_color_enhancement(self, img: Image.Image) -> bool:
491
+ """Check if image needs color enhancement."""
492
+ if img.mode != 'RGB':
493
+ return False
494
+
495
+ # Simple saturation check
496
+ try:
497
+ # Convert to HSV and check saturation
498
+ hsv = img.convert('HSV')
499
+ saturation_histogram = hsv.split()[1].histogram()
500
+ avg_saturation = sum(i * saturation_histogram[i] for i in range(256)) / sum(saturation_histogram)
501
+ return avg_saturation < 100 # Low saturation threshold
502
+ except:
503
+ return False
504
+
505
+
506
+ class QualityMonitor:
507
+ """Monitor and track quality metrics across uploads."""
508
+
509
+ def __init__(self, logger=None):
510
+ self.logger = logger
511
+ self.quality_metrics = []
512
+ self.performance_benchmarks = {
513
+ 'optimization_time': [],
514
+ 'size_reductions': [],
515
+ 'quality_scores': []
516
+ }
517
+
518
+ def record_quality_metrics(self, file_path: Path, analysis: Dict, optimization: Dict):
519
+ """Record quality metrics for monitoring."""
520
+ metrics = {
521
+ 'timestamp': time.time(),
522
+ 'file_path': str(file_path),
523
+ 'original_size': analysis['metrics']['file_size_mb'],
524
+ 'quality_score': analysis['quality_score'],
525
+ 'optimization_success': optimization.get('success', False),
526
+ 'size_reduction': optimization.get('size_reduction', 0.0)
527
+ }
528
+
529
+ self.quality_metrics.append(metrics)
530
+ self._update_benchmarks(metrics)
531
+
532
+ def _update_benchmarks(self, metrics: Dict):
533
+ """Update performance benchmarks."""
534
+ self.performance_benchmarks['size_reductions'].append(metrics['size_reduction'])
535
+ self.performance_benchmarks['quality_scores'].append(metrics['quality_score'])
536
+
537
+ def get_quality_report(self) -> Dict:
538
+ """Generate quality performance report."""
539
+ if not self.quality_metrics:
540
+ return {'message': 'No quality data available'}
541
+
542
+ total_files = len(self.quality_metrics)
543
+ successful_optimizations = sum(1 for m in self.quality_metrics if m['optimization_success'])
544
+
545
+ avg_quality_score = sum(m['quality_score'] for m in self.quality_metrics) / total_files
546
+ avg_size_reduction = sum(m['size_reduction'] for m in self.quality_metrics) / total_files
547
+
548
+ return {
549
+ 'total_files_processed': total_files,
550
+ 'successful_optimizations': successful_optimizations,
551
+ 'success_rate': successful_optimizations / total_files * 100,
552
+ 'average_quality_score': avg_quality_score,
553
+ 'average_size_reduction': avg_size_reduction * 100,
554
+ 'total_data_saved_mb': sum(
555
+ m['original_size'] * m['size_reduction']
556
+ for m in self.quality_metrics
557
+ )
558
+ }