feat: Add hybrid quality evaluation system with CLIP and VLM support
- Add FeatureExtractor for CLIP-based image/text feature extraction - Add ObjectiveMetricsCalculator for technical quality metrics - Add VLMEvaluator for vision language model evaluation - Add HybridQualityGate combining objective + VLM evaluation - Enhance CharacterMemory with visual feature support - Add quality optional dependency (torch, ftfy, regex) - Add unit tests for new modules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
232
pixelle_video/services/quality/objective_metrics.py
Normal file
232
pixelle_video/services/quality/objective_metrics.py
Normal file
@@ -0,0 +1,232 @@
|
||||
# Copyright (C) 2025 AIDC-AI
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
ObjectiveMetrics - Technical image quality metrics
|
||||
|
||||
Provides fast, local computation of:
|
||||
1. Sharpness/clarity detection
|
||||
2. Color distribution analysis
|
||||
3. Exposure detection (over/under)
|
||||
|
||||
Only depends on PIL and numpy (no heavy ML dependencies).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Tuple
|
||||
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class TechnicalMetrics:
|
||||
"""Technical quality metrics for an image"""
|
||||
|
||||
# Sharpness (0.0-1.0, higher = sharper)
|
||||
sharpness_score: float = 0.0
|
||||
|
||||
# Color metrics
|
||||
color_variance: float = 0.0
|
||||
saturation_score: float = 0.0
|
||||
|
||||
# Exposure metrics
|
||||
brightness_score: float = 0.0
|
||||
is_overexposed: bool = False
|
||||
is_underexposed: bool = False
|
||||
|
||||
# Overall technical score
|
||||
overall_technical: float = 0.0
|
||||
|
||||
# Issues detected
|
||||
issues: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"sharpness_score": self.sharpness_score,
|
||||
"color_variance": self.color_variance,
|
||||
"saturation_score": self.saturation_score,
|
||||
"brightness_score": self.brightness_score,
|
||||
"is_overexposed": self.is_overexposed,
|
||||
"is_underexposed": self.is_underexposed,
|
||||
"overall_technical": self.overall_technical,
|
||||
"issues": self.issues,
|
||||
}
|
||||
|
||||
|
||||
class ObjectiveMetricsCalculator:
|
||||
"""
|
||||
Calculate objective technical quality metrics
|
||||
|
||||
All computations are local and fast (no external API calls).
|
||||
Uses PIL and numpy only.
|
||||
|
||||
Example:
|
||||
>>> calculator = ObjectiveMetricsCalculator()
|
||||
>>> metrics = calculator.analyze_image("frame_001.png")
|
||||
>>> print(f"Sharpness: {metrics.sharpness_score:.2f}")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sharpness_threshold: float = 0.3,
|
||||
overexposure_threshold: float = 0.85,
|
||||
underexposure_threshold: float = 0.15,
|
||||
):
|
||||
self.sharpness_threshold = sharpness_threshold
|
||||
self.overexposure_threshold = overexposure_threshold
|
||||
self.underexposure_threshold = underexposure_threshold
|
||||
|
||||
def analyze_image(self, image_path: str) -> TechnicalMetrics:
|
||||
"""
|
||||
Analyze image and return technical metrics
|
||||
|
||||
Args:
|
||||
image_path: Path to image file
|
||||
|
||||
Returns:
|
||||
TechnicalMetrics with all computed values
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
img_rgb = img.convert("RGB")
|
||||
img_array = np.array(img_rgb)
|
||||
|
||||
# Calculate individual metrics
|
||||
sharpness = self._calculate_sharpness(img_rgb)
|
||||
color_var, saturation = self._calculate_color_metrics(img_array)
|
||||
brightness, overexp, underexp = self._calculate_exposure(img_array)
|
||||
|
||||
# Detect issues
|
||||
issues = []
|
||||
if sharpness < self.sharpness_threshold:
|
||||
issues.append("Image appears blurry")
|
||||
if overexp:
|
||||
issues.append("Image is overexposed")
|
||||
if underexp:
|
||||
issues.append("Image is underexposed")
|
||||
if color_var < 0.1:
|
||||
issues.append("Low color diversity")
|
||||
|
||||
# Calculate overall technical score
|
||||
overall = self._calculate_overall(
|
||||
sharpness, color_var, saturation, brightness
|
||||
)
|
||||
|
||||
return TechnicalMetrics(
|
||||
sharpness_score=sharpness,
|
||||
color_variance=color_var,
|
||||
saturation_score=saturation,
|
||||
brightness_score=brightness,
|
||||
is_overexposed=overexp,
|
||||
is_underexposed=underexp,
|
||||
overall_technical=overall,
|
||||
issues=issues
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to analyze image: {e}")
|
||||
return TechnicalMetrics(issues=[f"Analysis failed: {str(e)}"])
|
||||
|
||||
def _calculate_sharpness(self, img) -> float:
|
||||
"""Calculate sharpness using edge detection"""
|
||||
from PIL import ImageFilter
|
||||
|
||||
# Convert to grayscale
|
||||
gray = img.convert("L")
|
||||
|
||||
# Apply edge detection
|
||||
edges = gray.filter(ImageFilter.FIND_EDGES)
|
||||
edge_array = np.array(edges).astype(np.float32)
|
||||
|
||||
# Variance of edge response
|
||||
variance = np.var(edge_array)
|
||||
|
||||
# Normalize to 0-1 (empirical scaling)
|
||||
sharpness = min(1.0, variance / 2000)
|
||||
|
||||
return float(sharpness)
|
||||
|
||||
def _calculate_color_metrics(
|
||||
self,
|
||||
img_array: np.ndarray
|
||||
) -> Tuple[float, float]:
|
||||
"""Calculate color variance and saturation"""
|
||||
r, g, b = img_array[:, :, 0], img_array[:, :, 1], img_array[:, :, 2]
|
||||
|
||||
max_rgb = np.maximum(np.maximum(r, g), b)
|
||||
min_rgb = np.minimum(np.minimum(r, g), b)
|
||||
|
||||
# Saturation
|
||||
delta = max_rgb - min_rgb
|
||||
saturation = np.where(max_rgb > 0, delta / (max_rgb + 1e-7), 0)
|
||||
avg_saturation = np.mean(saturation)
|
||||
|
||||
# Color variance (diversity)
|
||||
color_std = np.std(img_array.reshape(-1, 3), axis=0)
|
||||
color_variance = np.mean(color_std) / 128
|
||||
|
||||
return float(color_variance), float(avg_saturation)
|
||||
|
||||
def _calculate_exposure(
|
||||
self,
|
||||
img_array: np.ndarray
|
||||
) -> Tuple[float, bool, bool]:
|
||||
"""Calculate brightness and detect exposure issues"""
|
||||
# Calculate luminance
|
||||
luminance = (
|
||||
0.299 * img_array[:, :, 0] +
|
||||
0.587 * img_array[:, :, 1] +
|
||||
0.114 * img_array[:, :, 2]
|
||||
) / 255.0
|
||||
|
||||
avg_brightness = float(np.mean(luminance))
|
||||
|
||||
# Check for over/under exposure
|
||||
overexposed_pixels = np.mean(luminance > 0.95)
|
||||
underexposed_pixels = np.mean(luminance < 0.05)
|
||||
|
||||
is_overexposed = (
|
||||
avg_brightness > self.overexposure_threshold or
|
||||
overexposed_pixels > 0.3
|
||||
)
|
||||
is_underexposed = (
|
||||
avg_brightness < self.underexposure_threshold or
|
||||
underexposed_pixels > 0.3
|
||||
)
|
||||
|
||||
return avg_brightness, is_overexposed, is_underexposed
|
||||
|
||||
def _calculate_overall(
|
||||
self,
|
||||
sharpness: float,
|
||||
color_var: float,
|
||||
saturation: float,
|
||||
brightness: float
|
||||
) -> float:
|
||||
"""Calculate overall technical score"""
|
||||
# Brightness penalty (ideal is 0.5)
|
||||
brightness_score = 1.0 - abs(brightness - 0.5) * 2
|
||||
brightness_score = max(0, brightness_score)
|
||||
|
||||
# Weighted combination
|
||||
overall = (
|
||||
sharpness * 0.4 +
|
||||
min(1.0, color_var * 2) * 0.2 +
|
||||
saturation * 0.2 +
|
||||
brightness_score * 0.2
|
||||
)
|
||||
|
||||
return float(overall)
|
||||
Reference in New Issue
Block a user