feat: add low contrast visibility analysis

This commit is contained in:
2026-06-16 11:21:21 +08:00
parent 43a096366f
commit c94b600bb6
4 changed files with 299 additions and 16 deletions
+19
View File
@@ -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
+156
View File
@@ -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)