feat: add low contrast visibility analysis
This commit is contained in:
@@ -94,6 +94,14 @@ class BatchMode(str, Enum):
|
||||
FULL_COMBO = "full_combo" # 全组合(矩阵)
|
||||
|
||||
|
||||
class VisibilityStatus(str, Enum):
|
||||
"""衣服目标区域与印花的可见度状态。"""
|
||||
NORMAL = "正常"
|
||||
LOW = "偏低"
|
||||
UNCLEAR = "不明显"
|
||||
UNKNOWN = "无法判断"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchOptions:
|
||||
"""批量任务配置。"""
|
||||
@@ -101,6 +109,16 @@ class BatchOptions:
|
||||
export_options: ExportOptions = field(default_factory=ExportOptions)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VisibilityResult:
|
||||
"""衣服目标区域与印花颜色对比分析结果。"""
|
||||
status: VisibilityStatus
|
||||
score: float = 0.0
|
||||
rgb_distance: float = 0.0
|
||||
brightness_difference: float = 0.0
|
||||
error: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 合成结果
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -113,6 +131,7 @@ class ComposeResult:
|
||||
print_path: Optional[Path] = None
|
||||
output_path: Optional[Path] = None # 成功时有效
|
||||
error: str = "" # 失败时的错误说明
|
||||
visibility: Optional[VisibilityResult] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Tuple, Union
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from core.models import TransformState, VisibilityResult, VisibilityStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PathLike = Union[str, Path]
|
||||
Color = Tuple[float, float, float]
|
||||
|
||||
ALPHA_THRESHOLD = 20
|
||||
UNCLEAR_RGB_DISTANCE_THRESHOLD = 45.0
|
||||
UNCLEAR_BRIGHTNESS_THRESHOLD = 35.0
|
||||
LOW_RGB_DISTANCE_THRESHOLD = 75.0
|
||||
LOW_BRIGHTNESS_THRESHOLD = 60.0
|
||||
|
||||
|
||||
def analyze_visibility(
|
||||
garment_path: PathLike,
|
||||
print_path: PathLike,
|
||||
transform: TransformState,
|
||||
) -> VisibilityResult:
|
||||
"""Analyze whether a print is visible enough on the target garment area.
|
||||
|
||||
This function does not write files and does not mutate source images. It
|
||||
reads the garment target rectangle from TransformState and compares that
|
||||
area's average color with the print's alpha-valid pixels.
|
||||
"""
|
||||
garment_path = Path(garment_path)
|
||||
print_path = Path(print_path)
|
||||
|
||||
try:
|
||||
if transform.width <= 0 or transform.height <= 0:
|
||||
return _unknown("Invalid transform dimensions")
|
||||
|
||||
with Image.open(str(garment_path)) as garment_src:
|
||||
garment = garment_src.convert("RGBA")
|
||||
with Image.open(str(print_path)) as print_src:
|
||||
print_img = print_src.convert("RGBA")
|
||||
|
||||
garment_region = _crop_garment_region(garment, transform)
|
||||
if garment_region is None:
|
||||
return _unknown("Print target area is outside garment canvas")
|
||||
|
||||
target_w = max(1, int(round(transform.width)))
|
||||
target_h = max(1, int(round(transform.height)))
|
||||
print_img = print_img.resize((target_w, target_h), Image.LANCZOS)
|
||||
|
||||
garment_color = _average_rgb(pixel[:3] for pixel in garment_region.getdata())
|
||||
print_color = _average_alpha_valid_rgb(print_img)
|
||||
if print_color is None:
|
||||
return _unknown("Print has no alpha-valid pixels")
|
||||
|
||||
rgb_distance = _rgb_distance(garment_color, print_color)
|
||||
brightness_difference = abs(_brightness(garment_color) - _brightness(print_color))
|
||||
status = _classify(rgb_distance, brightness_difference)
|
||||
score = _score(rgb_distance, brightness_difference)
|
||||
|
||||
return VisibilityResult(
|
||||
status=status,
|
||||
score=score,
|
||||
rgb_distance=rgb_distance,
|
||||
brightness_difference=brightness_difference,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Visibility analysis failed: %s + %s", garment_path, print_path)
|
||||
return _unknown(str(exc))
|
||||
|
||||
|
||||
def _crop_garment_region(garment: Image.Image, transform: TransformState):
|
||||
left = int(math.floor(transform.x))
|
||||
top = int(math.floor(transform.y))
|
||||
right = int(math.ceil(transform.x + transform.width))
|
||||
bottom = int(math.ceil(transform.y + transform.height))
|
||||
|
||||
left = max(0, left)
|
||||
top = max(0, top)
|
||||
right = min(garment.width, right)
|
||||
bottom = min(garment.height, bottom)
|
||||
|
||||
if right <= left or bottom <= top:
|
||||
return None
|
||||
return garment.crop((left, top, right, bottom))
|
||||
|
||||
|
||||
def _average_alpha_valid_rgb(image: Image.Image):
|
||||
pixels = (
|
||||
(r, g, b)
|
||||
for r, g, b, a in image.getdata()
|
||||
if a > ALPHA_THRESHOLD
|
||||
)
|
||||
return _average_rgb_or_none(pixels)
|
||||
|
||||
|
||||
def _average_rgb(pixels: Iterable[Color]) -> Color:
|
||||
avg = _average_rgb_or_none(pixels)
|
||||
if avg is None:
|
||||
return 0.0, 0.0, 0.0
|
||||
return avg
|
||||
|
||||
|
||||
def _average_rgb_or_none(pixels: Iterable[Color]):
|
||||
count = 0
|
||||
r_total = 0.0
|
||||
g_total = 0.0
|
||||
b_total = 0.0
|
||||
|
||||
for r, g, b in pixels:
|
||||
count += 1
|
||||
r_total += r
|
||||
g_total += g
|
||||
b_total += b
|
||||
|
||||
if count == 0:
|
||||
return None
|
||||
return r_total / count, g_total / count, b_total / count
|
||||
|
||||
|
||||
def _rgb_distance(a: Color, b: Color) -> float:
|
||||
return math.sqrt(
|
||||
(a[0] - b[0]) ** 2 +
|
||||
(a[1] - b[1]) ** 2 +
|
||||
(a[2] - b[2]) ** 2
|
||||
)
|
||||
|
||||
|
||||
def _brightness(color: Color) -> float:
|
||||
return 0.299 * color[0] + 0.587 * color[1] + 0.114 * color[2]
|
||||
|
||||
|
||||
def _classify(rgb_distance: float, brightness_difference: float) -> VisibilityStatus:
|
||||
if (
|
||||
rgb_distance < UNCLEAR_RGB_DISTANCE_THRESHOLD and
|
||||
brightness_difference < UNCLEAR_BRIGHTNESS_THRESHOLD
|
||||
):
|
||||
return VisibilityStatus.UNCLEAR
|
||||
if (
|
||||
rgb_distance < LOW_RGB_DISTANCE_THRESHOLD and
|
||||
brightness_difference < LOW_BRIGHTNESS_THRESHOLD
|
||||
):
|
||||
return VisibilityStatus.LOW
|
||||
return VisibilityStatus.NORMAL
|
||||
|
||||
|
||||
def _score(rgb_distance: float, brightness_difference: float) -> float:
|
||||
color_score = min(100.0, rgb_distance / 195.0 * 100.0)
|
||||
brightness_score = min(100.0, brightness_difference / 100.0 * 100.0)
|
||||
return max(color_score, brightness_score)
|
||||
|
||||
|
||||
def _unknown(error: str) -> VisibilityResult:
|
||||
return VisibilityResult(status=VisibilityStatus.UNKNOWN, error=error)
|
||||
Reference in New Issue
Block a user