You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
170 lines
4.1 KiB
170 lines
4.1 KiB
#!/usr/bin/env python3
|
|
|
|
#
|
|
# slides-alpha.py
|
|
#
|
|
# Generates an alpha layer for slides (in PNG format), trying to replace the
|
|
# white background while leaving a small border around any element so they are
|
|
# still visible / readable over anything dark
|
|
#
|
|
# Copyright (c) 2023 Sylvain Munaut <tnt@246tNt.com>
|
|
# SPDX-License-Identifier: MIT
|
|
#
|
|
|
|
import argparse
|
|
import os
|
|
import pathlib
|
|
import re
|
|
|
|
from collections import namedtuple
|
|
|
|
from PIL import Image
|
|
|
|
import numpy as np
|
|
|
|
import scipy.ndimage
|
|
import scipy.signal
|
|
|
|
|
|
CropSpec = namedtuple("CropSpec", "w h x y")
|
|
|
|
def croptype(s):
|
|
m = re.match("^([0-9]+)x([0-9]+)(\+([0-9]+)\+([0-9]+))?$", s)
|
|
if not m:
|
|
raise argparse.ArgumentTypeError(f"{s} is not a valid crop specification")
|
|
|
|
return CropSpec(
|
|
int(m.group(1)),
|
|
int(m.group(2)),
|
|
int(m.group(4) or 0),
|
|
int(m.group(5) or 0),
|
|
)
|
|
|
|
|
|
def slide_process_single(in_file, out_file, opts):
|
|
# Debug
|
|
print(f"Processing {str(in_file):s} -> {str(out_file):s}")
|
|
|
|
# Open image
|
|
img = Image.open(in_file)
|
|
|
|
# Convert to NP array
|
|
ia = np.array(img.convert('RGBA'))
|
|
|
|
# Crop
|
|
if opts.crop:
|
|
c = opts.crop
|
|
ia = ia[c.y:c.y+c.h,c.x:c.x+c.w]
|
|
|
|
# Create alpha channel
|
|
iaf = ia.astype(np.float32) / 255.0
|
|
x = 1.0 - (iaf[:,:,0] + iaf[:,:,1] + iaf[:,:,2]) / 3.0
|
|
y = np.where(x > 0.1, 1.0, 0.0)
|
|
|
|
# Borders
|
|
if opts.top_border:
|
|
y[0:opts.top_border, :] = np.zeros([opts.top_border, y.shape[1]], dtype=np.float32)
|
|
|
|
if opts.bottom_border:
|
|
y[-opts.bottom_border:, :] = np.zeros([opts.bottom_border, y.shape[1]], dtype=np.float32)
|
|
|
|
# Expand outward
|
|
r = np.max(y.shape) // 384
|
|
ye = scipy.signal.convolve2d(y, np.ones([r,r]), mode='same', boundary='fill', fillvalue=0)
|
|
ye = ye.clip(0.0, 1.0)
|
|
|
|
# Blur it
|
|
r = np.max(y.shape) / 1280
|
|
ye = scipy.ndimage.gaussian_filter(ye, r, mode='nearest')
|
|
|
|
# Recombine
|
|
y = (y + ye).clip(0.0, 1.0)
|
|
#y = np.maximum(y, x)
|
|
|
|
# Borders
|
|
if opts.top_border:
|
|
y[0:opts.top_border, :] = np.ones([opts.top_border, y.shape[1]], dtype=np.float32)
|
|
|
|
if opts.bottom_border:
|
|
y[-opts.bottom_border:, :] = np.ones([opts.bottom_border, y.shape[1]], dtype=np.float32)
|
|
|
|
# Inject in original image
|
|
ia[:,:,3] = (y * 255).astype(np.uint8)
|
|
|
|
# Save
|
|
img = Image.fromarray(ia)
|
|
img.save(out_file)
|
|
|
|
|
|
def slide_process_directory(in_path, out_path, opts):
|
|
for spe in os.listdir(in_path):
|
|
# Create in/out path
|
|
spi = in_path / spe
|
|
spo = out_path / spe
|
|
|
|
# Skip hidden files
|
|
if spe.startswith('.'):
|
|
continue
|
|
|
|
# Is this a PNG file ?
|
|
if spe.endswith('.png') and spi.is_file():
|
|
slide_process_single(spi, spo, opts)
|
|
|
|
# Or a sub directory ?
|
|
if spi.is_dir():
|
|
spo.mkdir(exist_ok=opts.force)
|
|
slide_process_directory(spi, spo, opts)
|
|
|
|
|
|
def parse_opt():
|
|
parser = argparse.ArgumentParser(
|
|
prog = 'slides-alpha.py',
|
|
description = 'Alpha channel generator for slides',
|
|
)
|
|
|
|
parser.add_argument('-i', '--input', required=True, type=pathlib.Path,
|
|
help="Path to either input file or input directory to scan")
|
|
parser.add_argument('-o', '--output', required=True, type=pathlib.Path,
|
|
help="Path to either output file or output dirctory to write results to")
|
|
parser.add_argument('-c', '--crop', type=croptype,
|
|
help="Crop (WxH[+X+Y])")
|
|
parser.add_argument('-t', '--top-border', type=int, default=0,
|
|
help="Size in pixel of the solid top border")
|
|
parser.add_argument('-b', '--bottom-border', type=int, default=0,
|
|
help="Size in pixel of the solid bottom border")
|
|
parser.add_argument('-f', '--force', action='store_true',
|
|
help="Overwrite destination if it exists")
|
|
|
|
return parser.parse_args()
|
|
|
|
|
|
def main():
|
|
# Parse option
|
|
opts = parse_opt()
|
|
|
|
# Check single or recurse mode
|
|
if opts.input.is_dir():
|
|
# Directory
|
|
if not opts.output.is_dir():
|
|
raise RuntimeError("Output directory is not valid")
|
|
|
|
slide_process_directory(opts.input, opts.output, opts)
|
|
|
|
elif opts.input.is_file():
|
|
# Single file
|
|
if opts.output.exists():
|
|
if not opts.output.is_file():
|
|
raise RuntimeError("Output path already exists and is not a file")
|
|
elif not opts.force:
|
|
raise RuntimeError("Output path already exists")
|
|
|
|
slide_process_single(opts.input, opts.output, opts)
|
|
|
|
else:
|
|
# Huh ???
|
|
raise RuntimeError("No input file ?")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|