"""图片处理流水线
处理流程:双页拆分 → 裁白边 → 缩放 → 灰度 → 对比度增强
各步骤均可通过 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)