- 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>
233 lines
7.3 KiB
Python
233 lines
7.3 KiB
Python
# 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)
|