~cytrogen/kobo-manga

ref: 4e504823f4bf8d2b5f4279da3f4d4ebe98fc97ad kobo-manga/src/kobo_manga/processor/pipeline.py -rw-r--r-- 4.8 KiB
4e504823 — HallowDem Initial commit: kobo-manga pipeline a day ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
"""图片处理流水线

处理流程:双页拆分 → 裁白边 → 缩放 → 灰度 → 对比度增强
各步骤均可通过 config 开关控制。
"""

from pathlib import Path

from PIL import Image, ImageEnhance, ImageOps

from kobo_manga.config import DeviceConfig, ProcessingConfig


class ImageProcessor:
    """图片处理器,将漫画原图优化为适合 e-ink 设备阅读的格式。"""

    def __init__(self, processing: ProcessingConfig, device: DeviceConfig):
        self.processing = processing
        self.device = device

    def process_image(self, img: Image.Image) -> list[Image.Image]:
        """执行完整处理流水线。

        返回列表是因为双页拆分可能产生 2 张图片。
        """
        images = [img]

        if self.processing.split_double_page:
            images = self._split_double_pages(images)

        result = []
        for im in images:
            if self.processing.crop_whitespace:
                im = self._crop_whitespace(im)
            if self.processing.resize:
                im = self._resize(im)
            if self.processing.grayscale and not self.device.color:
                im = self._grayscale(im)
            if self.processing.enhance_contrast:
                im = self._enhance_contrast(im)
            result.append(im)

        return result

    def process_chapter(
        self, image_paths: list[Path], output_dir: Path
    ) -> list[Path]:
        """处理整个章节的图片,返回输出文件路径列表。

        逐张处理以控制内存占用。
        """
        output_dir.mkdir(parents=True, exist_ok=True)
        output_paths = []
        page_num = 1

        for path in image_paths:
            img = Image.open(path)
            processed = self.process_image(img)
            img.close()

            for p_img in processed:
                out_path = output_dir / f"page_{page_num:03d}.jpg"
                # 灰度图保存为 L 模式 JPEG
                if p_img.mode == "L":
                    p_img.save(out_path, "JPEG", quality=85)
                else:
                    p_img.save(out_path, "JPEG", quality=85)
                p_img.close()
                output_paths.append(out_path)
                page_num += 1

        return output_paths

    # ── 流水线各步骤 ──────────────────────────────────────

    def _split_double_pages(
        self, images: list[Image.Image]
    ) -> list[Image.Image]:
        """检测并拆分双页。宽高比 > 1.2 视为双页。

        日漫从右往左读,所以右半页在前。
        """
        result = []
        for img in images:
            w, h = img.size
            if w > h * 1.2:
                mid = w // 2
                right = img.crop((mid, 0, w, h))
                left = img.crop((0, 0, mid, h))
                result.extend([right, left])
            else:
                result.append(img)
        return result

    def _crop_whitespace(self, img: Image.Image) -> Image.Image:
        """裁剪图片周围的白色/浅色边框。"""
        # 转灰度检测边界
        if img.mode == "RGBA":
            gray = img.convert("RGB").convert("L")
        else:
            gray = img.convert("L")

        # 反转后白边变黑(0),getbbox() 找非零区域
        inverted = ImageOps.invert(gray)
        bbox = inverted.getbbox()

        if bbox is None:
            # 全白页,不裁剪
            return img

        # 安全检查:裁剪后面积不应小于原图 50%
        orig_area = img.size[0] * img.size[1]
        crop_w = bbox[2] - bbox[0]
        crop_h = bbox[3] - bbox[1]
        if crop_w * crop_h < orig_area * 0.5:
            return img

        # 加 2px margin
        margin = 2
        x0 = max(0, bbox[0] - margin)
        y0 = max(0, bbox[1] - margin)
        x1 = min(img.size[0], bbox[2] + margin)
        y1 = min(img.size[1], bbox[3] + margin)

        return img.crop((x0, y0, x1, y1))

    def _resize(self, img: Image.Image) -> Image.Image:
        """缩放到设备分辨率内,保持宽高比,只缩小不放大。"""
        w, h = img.size
        target_w = self.device.width
        target_h = self.device.height

        # 已经小于等于目标尺寸,不处理
        if w <= target_w and h <= target_h:
            return img

        scale = min(target_w / w, target_h / h)
        new_w = int(w * scale)
        new_h = int(h * scale)

        return img.resize((new_w, new_h), Image.Resampling.LANCZOS)

    def _grayscale(self, img: Image.Image) -> Image.Image:
        """转灰度。"""
        return img.convert("L")

    def _enhance_contrast(self, img: Image.Image) -> Image.Image:
        """增强对比度。"""
        enhancer = ImageEnhance.Contrast(img)
        return enhancer.enhance(self.processing.contrast_factor)