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)
+16 -16
View File
@@ -265,25 +265,25 @@
任务:
- [ ] 定义可见度状态:`正常`、`偏低`、`不明显`、`无法判断`
- [ ] 新增低对比分析核心函数,位置应符合架构分层,不能写进 UI 事件
- [ ] 基于当前模板或 `TransformState` 获取衣服目标区域
- [ ] 只分析衣服目标区域颜色,不使用整张衣服图判断
- [ ] 只统计印花 alpha 有效像素,例如 `alpha > 20`
- [ ] 计算 RGB 颜色距离
- [ ] 计算亮度差
- [ ] 根据阈值输出可见度状态和评分
- [ ] 单个组合分析失败时返回 `无法判断`,不得中断整个队列
- [ ] 为后续队列项保存可见度结果预留字段或结果结构
- [x] 定义可见度状态:`正常`、`偏低`、`不明显`、`无法判断`
- [x] 新增低对比分析核心函数,位置应符合架构分层,不能写进 UI 事件
- [x] 基于当前模板或 `TransformState` 获取衣服目标区域
- [x] 只分析衣服目标区域颜色,不使用整张衣服图判断
- [x] 只统计印花 alpha 有效像素,例如 `alpha > 20`
- [x] 计算 RGB 颜色距离
- [x] 计算亮度差
- [x] 根据阈值输出可见度状态和评分
- [x] 单个组合分析失败时返回 `无法判断`,不得中断整个队列
- [x] 为后续队列项保存可见度结果预留字段或结果结构
验收:
- [ ] 白色衣服配浅色印花时可标记为 `偏低` 或 `不明显`
- [ ] 深色衣服配深色印花时可标记为 `偏低` 或 `不明显`
- [ ] 透明 PNG 的透明区域不参与印花颜色判断
- [ ] 低对比分析不修改原始衣服图片和原始印花图片
- [ ] 分析逻辑不依赖 PySide6 UI 控件
- [ ] 失败项不会中断批量队列生成或导出流程
- [x] 白色衣服配浅色印花时可标记为 `偏低` 或 `不明显`
- [x] 深色衣服配深色印花时可标记为 `偏低` 或 `不明显`
- [x] 透明 PNG 的透明区域不参与印花颜色判断
- [x] 低对比分析不修改原始衣服图片和原始印花图片
- [x] 分析逻辑不依赖 PySide6 UI 控件
- [x] 失败项不会中断批量队列生成或导出流程
## 7. 主界面布局
+108
View File
@@ -0,0 +1,108 @@
"""Tests for low-contrast visibility analysis. No GUI dependency."""
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from PIL import Image
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from core.models import TransformState, VisibilityStatus
from core.visibility import analyze_visibility
class TestVisibilityAnalysis(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
def tearDown(self):
shutil.rmtree(str(self.tmp), ignore_errors=True)
def _save(self, image, name):
path = self.tmp / name
image.save(str(path))
return path
def _rgba(self, w, h, color):
return Image.new("RGBA", (w, h), color)
def test_white_garment_with_light_print_is_unclear_or_low(self):
garment = self._save(self._rgba(100, 100, (245, 245, 245, 255)), "garment.png")
print_img = self._save(self._rgba(20, 20, (250, 250, 250, 255)), "print.png")
state = TransformState(x=30, y=30, width=20, height=20)
result = analyze_visibility(garment, print_img, state)
self.assertIn(result.status, (VisibilityStatus.UNCLEAR, VisibilityStatus.LOW))
self.assertFalse(result.error)
def test_dark_garment_with_dark_print_is_unclear_or_low(self):
garment = self._save(self._rgba(100, 100, (20, 20, 20, 255)), "garment.png")
print_img = self._save(self._rgba(20, 20, (25, 25, 25, 255)), "print.png")
state = TransformState(x=30, y=30, width=20, height=20)
result = analyze_visibility(garment, print_img, state)
self.assertIn(result.status, (VisibilityStatus.UNCLEAR, VisibilityStatus.LOW))
self.assertFalse(result.error)
def test_high_contrast_pair_is_normal(self):
garment = self._save(self._rgba(100, 100, (250, 250, 250, 255)), "garment.png")
print_img = self._save(self._rgba(20, 20, (10, 10, 10, 255)), "print.png")
state = TransformState(x=30, y=30, width=20, height=20)
result = analyze_visibility(garment, print_img, state)
self.assertEqual(result.status, VisibilityStatus.NORMAL)
self.assertGreater(result.rgb_distance, 75)
def test_transparent_pixels_do_not_affect_print_color(self):
garment = self._save(self._rgba(100, 100, (10, 10, 10, 255)), "garment.png")
print_img = Image.new("RGBA", (20, 20), (255, 255, 255, 0))
for x in range(10):
for y in range(20):
print_img.putpixel((x, y), (12, 12, 12, 255))
print_path = self._save(print_img, "print.png")
state = TransformState(x=30, y=30, width=20, height=20)
result = analyze_visibility(garment, print_path, state)
self.assertIn(result.status, (VisibilityStatus.UNCLEAR, VisibilityStatus.LOW))
def test_fully_transparent_print_is_unknown(self):
garment = self._save(self._rgba(100, 100, (10, 10, 10, 255)), "garment.png")
print_img = self._save(self._rgba(20, 20, (10, 10, 10, 0)), "print.png")
state = TransformState(x=30, y=30, width=20, height=20)
result = analyze_visibility(garment, print_img, state)
self.assertEqual(result.status, VisibilityStatus.UNKNOWN)
self.assertTrue(result.error)
def test_uses_target_region_not_whole_garment(self):
garment_img = self._rgba(100, 100, (255, 255, 255, 255))
for x in range(30, 50):
for y in range(30, 50):
garment_img.putpixel((x, y), (10, 10, 10, 255))
garment = self._save(garment_img, "garment.png")
print_img = self._save(self._rgba(20, 20, (12, 12, 12, 255)), "print.png")
state = TransformState(x=30, y=30, width=20, height=20)
result = analyze_visibility(garment, print_img, state)
self.assertIn(result.status, (VisibilityStatus.UNCLEAR, VisibilityStatus.LOW))
def test_target_area_outside_canvas_is_unknown(self):
garment = self._save(self._rgba(100, 100, (255, 255, 255, 255)), "garment.png")
print_img = self._save(self._rgba(20, 20, (255, 255, 255, 255)), "print.png")
state = TransformState(x=200, y=200, width=20, height=20)
result = analyze_visibility(garment, print_img, state)
self.assertEqual(result.status, VisibilityStatus.UNKNOWN)
if __name__ == "__main__":
unittest.main()