diff --git a/src/core/models.py b/src/core/models.py index b0f5557..ca2ec79 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -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 diff --git a/src/core/visibility.py b/src/core/visibility.py new file mode 100644 index 0000000..313e297 --- /dev/null +++ b/src/core/visibility.py @@ -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) diff --git a/tasks.md b/tasks.md index e13983f..a17c527 100644 --- a/tasks.md +++ b/tasks.md @@ -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. 主界面布局 diff --git a/tests/test_visibility.py b/tests/test_visibility.py new file mode 100644 index 0000000..7d71f62 --- /dev/null +++ b/tests/test_visibility.py @@ -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()