88 lines
3.5 KiB
Python
88 lines
3.5 KiB
Python
"""Crop the photographed Keyes modules and create README-ready WebP assets."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageOps
|
|
|
|
|
|
COMPONENTS_DIR = Path(__file__).resolve().parents[1] / "components"
|
|
|
|
PHOTOS = {
|
|
"keyestudio-ks0037-vibration": "IMG_20261024_203426885_HDR.jpg",
|
|
"keyestudio-ks0019-passive-buzzer": "IMG_20261024_203440803_HDR.jpg",
|
|
"keyes-ke2054-laser-emitter": "IMG_20261024_203450304_HDR.jpg",
|
|
"keyestudio-ks0033-thermistor": "IMG_20261024_203506743_HDR.jpg",
|
|
"keyestudio-ks0024-knock": "IMG_20261024_203515354_HDR.jpg",
|
|
"keyestudio-ks0028-photocell": "IMG_20261024_203524523_HDR.jpg",
|
|
"keyestudio-ks0014-analog-rotation": "IMG_20261024_203538683_HDR.jpg",
|
|
"keyestudio-ks0023-ds18b20": "IMG_20261024_203559430_BURST000_COVER.jpg",
|
|
"keyestudio-ks0029-push-button": "IMG_20261024_203615490_HDR.jpg",
|
|
"keyestudio-ks0038-reed-switch": "IMG_20261024_203624532_HDR.jpg",
|
|
"keyestudio-ks0035-microphone": "IMG_20261024_203651498_HDR.jpg",
|
|
"keyestudio-ks0025-tilt": "IMG_20261024_203705101_HDR.jpg",
|
|
"keyestudio-ks0026-ir-receiver": "IMG_20261024_203723952_HDR.jpg",
|
|
"keyestudio-ks0018-active-buzzer": "IMG_20261024_203734801_HDR.jpg",
|
|
"keyestudio-ks0027-ir-transmitter": "IMG_20261024_203743461_HDR.jpg",
|
|
"keyestudio-ks0015-pulse-rate": "IMG_20261024_203754423_HDR.jpg",
|
|
"keyestudio-ks0022-lm35": "IMG_20261024_203804368_HDR.jpg",
|
|
"keyestudio-ks0021-collision": "IMG_20261024_203811921_HDR.jpg",
|
|
"keyestudio-ks0032-rgb-led": "IMG_20261024_203820913_HDR.jpg",
|
|
}
|
|
|
|
|
|
def crop_around_board(image: Image.Image) -> Image.Image:
|
|
"""Find the red PCB, then retain generous room for protruding components."""
|
|
pixels = np.asarray(image.convert("RGB"))
|
|
red = pixels[:, :, 0].astype(np.int16)
|
|
green = pixels[:, :, 1].astype(np.int16)
|
|
blue = pixels[:, :, 2].astype(np.int16)
|
|
mask = (red > 125) & (red > green * 1.35) & (red > blue * 1.20)
|
|
ys, xs = np.nonzero(mask)
|
|
if len(xs) < 10_000:
|
|
raise RuntimeError("Could not locate the red PCB")
|
|
|
|
left, right = int(np.percentile(xs, 0.2)), int(np.percentile(xs, 99.8))
|
|
top, bottom = int(np.percentile(ys, 0.2)), int(np.percentile(ys, 99.8))
|
|
width, height = right - left, bottom - top
|
|
pad_x, pad_y = int(width * 0.22), int(height * 0.18)
|
|
box = (
|
|
max(0, left - pad_x),
|
|
max(0, top - pad_y),
|
|
min(image.width, right + pad_x),
|
|
min(image.height, bottom + pad_y),
|
|
)
|
|
return image.crop(box)
|
|
|
|
|
|
def fit(image: Image.Image, max_size: tuple[int, int]) -> Image.Image:
|
|
result = image.copy()
|
|
result.thumbnail(max_size, Image.Resampling.LANCZOS)
|
|
return result
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("source_dir", type=Path, help="Directory containing the source JPEGs")
|
|
args = parser.parse_args()
|
|
|
|
for component, filename in PHOTOS.items():
|
|
source = args.source_dir / filename
|
|
destination = COMPONENTS_DIR / component
|
|
with Image.open(source) as opened:
|
|
image = ImageOps.exif_transpose(opened).convert("RGB")
|
|
crop = crop_around_board(image)
|
|
fit(crop, (1400, 1050)).save(
|
|
destination / "image.webp", "WEBP", quality=90, method=6
|
|
)
|
|
fit(crop, (240, 180)).save(
|
|
destination / "thumbnail.webp", "WEBP", quality=90, method=6
|
|
)
|
|
print(f"{component}: {filename}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|