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)
|
||||
@@ -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. 主界面布局
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user