wallpaper: refactor a bit wallpaper module

This commit is contained in:
Vincent Bernat 2021-08-27 00:28:08 +02:00
parent 453ea504f3
commit fadf06d1b9

View file

@ -1,169 +1,230 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# Build a multi screen wallpaper """Build a multi screen wallpaper."""
# First argument is the directory where the wallpapers can be
# found. We use xinerama to know the dimension of each screen.
import os import os
import sys
import random import random
import argparse import argparse
import tempfile import tempfile
import collections import collections
import itertools import itertools
import logging
import logging.handlers
from Xlib import display from Xlib import display
from Xlib.ext import randr from Xlib.ext import randr
from PIL import Image from PIL import Image
from systemd import journal
parser = argparse.ArgumentParser()
parser.add_argument(
"-d",
"--directory",
default=".",
help="search for images in DIRECTORY",
)
parser.add_argument(
"-t",
"--target",
default="background.png",
help="write background to FILE",
)
parser.add_argument(
"--extra-images",
default=3,
metavar="N",
help="consider N additional images to choose the best combination",
)
parser.add_argument(
"--compression", default=0, type=int, help="compression level when saving"
)
options = parser.parse_args()
background = None
# Get display size logger = logging.getLogger("wallpaper")
d = display.Display() Rectangle = collections.namedtuple("Rectangle", ["x", "y", "width", "height"])
screen = d.screen() WallpaperPart = collections.namedtuple("WallpaperPart", ["rectangle", "image"])
window = screen.root.create_window(0, 0, 1, 1, 1, screen.root_depth)
background = Image.new("RGB", (screen.width_in_pixels, screen.height_in_pixels))
# Query randr extension def get_outputs():
Output = collections.namedtuple("Output", ["x", "y", "width", "height"]) """Get physical outputs."""
outputs = [] # Get display size
screen_resources = randr.get_screen_resources_current(window) d = display.Display()
for output in screen_resources.outputs: screen = d.screen()
output_info = randr.get_output_info(window, output, screen_resources.timestamp) window = screen.root.create_window(0, 0, 1, 1, 1, screen.root_depth)
if output_info.crtc == 0: background = Image.new("RGB", (screen.width_in_pixels, screen.height_in_pixels))
continue
crtc_info = randr.get_crtc_info(window, output_info.crtc, output_info.timestamp)
outputs.append(Output(crtc_info.x, crtc_info.y, crtc_info.width, crtc_info.height))
if not outputs:
outputs.append(Output(0, 0, (background.width, background.height)))
# Get all possible rectangles from outputs # Query randr extension
candidates = [] outputs = []
for output in outputs: screen_resources = randr.get_screen_resources_current(window)
candidates.append(output) for output in screen_resources.outputs:
for ooutput in outputs: output_info = randr.get_output_info(window, output, screen_resources.timestamp)
if ooutput == output: if output_info.crtc == 0:
continue continue
if output.x > ooutput.x or output.y > ooutput.y: crtc_info = randr.get_crtc_info(window, output_info.crtc, output_info.timestamp)
continue outputs.append(Rectangle(crtc_info.x, crtc_info.y, crtc_info.width, crtc_info.height))
candidates.append(
Output( for o in outputs:
output.x, logger.debug("output: %s", o)
output.y, return outputs, background
ooutput.x - output.x + ooutput.width,
ooutput.y - output.y + ooutput.height,
def get_covering_rectangles(outputs):
"""Compute all possible groups of covering boxes for the provided
outputs. For each group, an output is included in exactly one box.
>>> get_covering_rectangles([Rectangle(0, 0, 100, 100)])
{(Rectangle(x=0, y=0, width=100, height=100),)}
>>> get_covering_rectangles([Rectangle(0, 0, 100, 100), Rectangle(100, 0, 100, 100)])
{(Rectangle(x=0, y=0, width=100, height=100), Rectangle(x=100, y=0, width=100, height=100)), (Rectangle(x=0, y=0, width=200, height=100),)}
>>> get_covering_rectangles([Rectangle(0, 0, 100, 100), Rectangle(100, 0, 100, 100), Rectangle(0, 100, 100, 100)])
{(Rectangle(x=100, y=0, width=100, height=100), Rectangle(x=0, y=100, width=100, height=100), Rectangle(x=0, y=0, width=100, height=100)), (Rectangle(x=100, y=0, width=100, height=100), Rectangle(x=0, y=0, width=100, height=200)), (Rectangle(x=0, y=0, width=200, height=100), Rectangle(x=0, y=100, width=100, height=100))}
"""
candidates = set()
for output in outputs:
candidates.add(output)
for ooutput in outputs:
if ooutput == output:
continue
if output.x > ooutput.x or output.y > ooutput.y:
continue
candidates.add(
Rectangle(
output.x,
output.y,
ooutput.x - output.x + ooutput.width,
ooutput.y - output.y + ooutput.height,
)
)
# Get all rectangle combinations to cover outputs without overlapping
groups = set()
for r in range(len(candidates)):
for candidate in itertools.combinations(candidates, r + 1):
for output in outputs:
nb = 0
for c in candidate:
if c.x <= output.x < c.x + c.width and c.y <= output.y < c.y + c.height:
nb += 1
if nb != 1: # output not contained in a single rectangle
break
else:
groups.add(candidate)
for g in groups:
logger.debug("group: %s", g)
return groups
def get_random_images(directory, number):
"""Get random images from a directory."""
images = []
for base, _, files in os.walk(os.path.join(directory)):
for i in files:
if os.path.splitext(i)[1].lower() in (".jpg", ".jpeg", ".png", ".webp"):
images.append(os.path.join(base, i))
images = random.sample(images, number)
images = [Image.open(image) for image in images]
for image in images:
logger.debug("image: %s %s×%s", image.filename[(len(directory) + 1) :], *image.size)
return images
def get_best_parts(groups, images):
"""Find optimal association for images for the groups of covering rectangles."""
best_association = None
best_score = 0
for group in groups:
for association in (zip(group, p) for p in itertools.permutations(images)):
score = 0
association = [WallpaperPart(rectangle=assoc[0], image=assoc[1]) for assoc in association]
for assoc in association:
# Similar ratio
oratio = assoc.rectangle.width * 100 // assoc.rectangle.height
iratio = assoc.image.width * 100 // assoc.image.height
r = iratio / oratio
if r > 1:
r = 1 / r
score += r * 100
# Similar scale (when cropped)
opixels = assoc.rectangle.width * assoc.rectangle.height
ipixels = assoc.image.width * assoc.image.height * r
r = ipixels / opixels
if r >= 1:
r = 1
score += r * 50
score /= len(group)
logger.debug("association: %s, score %.2f", association, score)
if score > best_score or best_association is None:
best_association = association
best_score = score
return best_association
def build(background, wallpaper_parts):
"""Stitch wallpaper into provided background."""
for part in wallpaper_parts:
rectangle = part.rectangle
image = part.image
imx, imy = rectangle.width, image.height * rectangle.width // image.width
if imy < rectangle.height:
imx, imy = image.width * rectangle.height // image.height, rectangle.height
if image.size != (imx, imy):
image = image.resize((imx, imy), Image.LANCZOS)
image = image.crop(
(
(imx - rectangle.width) / 2,
(imy - rectangle.height) / 2,
imx - (imx - rectangle.width) / 2,
imy - (imy - rectangle.height) / 2,
) )
) )
background.paste(image, (rectangle.x, rectangle.y))
# Get all rectangle combinations to cover outputs without overlapping
groups = []
for r in range(len(candidates)):
for candidate in itertools.combinations(candidates, r + 1):
for output in outputs:
nb = 0
for c in candidate:
if c.x <= output.x < c.x + c.width and c.y <= output.y < c.y + c.height:
nb += 1
if nb != 1: # output not contained in a single rectangle
break
else:
groups.append(candidate)
# Get random images def save(wallpaper, target, compression):
images = [] """Save wallpaper to target."""
for base, _, files in os.walk(os.path.join(options.directory)): with tempfile.NamedTemporaryFile(
for i in files: delete=False, dir=os.path.dirname(os.path.realpath(target))
if os.path.splitext(i)[1].lower() in (".jpg", ".jpeg", ".png", ".webp"): ) as tmp:
images.append(os.path.join(base, i)) background.save(tmp, "png", compress_level=compression)
images = random.sample(images, len(outputs) * options.extra_images) os.rename(tmp.name, target)
images = [Image.open(image) for image in images]
# Find optimal combination for images if __name__ == "__main__":
best_association = None # Parse
best_score = 0 description = sys.modules[__name__].__doc__
for group in groups: parser = argparse.ArgumentParser()
for association in (zip(group, p) for p in itertools.permutations(images)): parser.add_argument(
score = 0 "--debug",
association = list(association) action="store_true",
for output, image in association: default=False,
# Similar ratio help="enable debugging",
oratio = output.width * 100 // output.height
iratio = image.width * 100 // image.height
r = iratio / oratio
if r > 1:
r = 1 / r
score += r * 100
# Similar scale (when cropped)
opixels = output.width * output.height
ipixels = image.width * image.height * r
r = ipixels / opixels
if r >= 1:
r = 1
score += r * 50
score /= len(group)
if score > best_score or best_association is None:
best_association = association
best_score = score
print(
"wallpaper: {}".format(
" + ".join(
"`{}` ({}×{})".format(
image.filename[(len(options.directory) + 1) :], *image.size
)
for image in [couple[1] for couple in best_association]
)
) )
) parser.add_argument(
"-d",
for couple in best_association: "--directory",
output = couple[0] default=".",
image = couple[1] help="search for images in DIRECTORY",
# Find the right size for the screen
imx, imy = output.width, image.height * output.width // image.width
if imy < output.height:
imx, imy = image.width * output.height // image.height, output.height
if image.size != (imx, imy):
image = image.resize((imx, imy), Image.LANCZOS)
image = image.crop(
(
(imx - output.width) / 2,
(imy - output.height) / 2,
imx - (imx - output.width) / 2,
imy - (imy - output.height) / 2,
)
) )
background.paste(image, (output.x, output.y)) parser.add_argument(
"-t",
"--target",
default="background.png",
help="write background to FILE",
)
parser.add_argument(
"--extra-images",
default=3,
metavar="N",
help="consider N additional images to choose the best combination",
)
parser.add_argument(
"--compression", default=0, type=int, help="compression level when saving"
)
options = parser.parse_args()
# Save # Logging
assert background, "Don't know the size of the display area" root = logging.getLogger("")
with tempfile.NamedTemporaryFile( root.setLevel(logging.WARNING)
delete=False, dir=os.path.dirname(os.path.realpath(options.target)) logger.setLevel(options.debug and logging.DEBUG or logging.INFO)
) as tmp: if sys.stderr.isatty():
background.save(tmp, "png", compress_level=options.compression) ch = logging.StreamHandler()
os.rename(tmp.name, options.target) ch.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
root.addHandler(ch)
else:
root.addHandler(journal.JournalHandler(SYSLOG_IDENTIFIER=logger.name))
try:
outputs, background = get_outputs()
candidates = get_covering_rectangles(outputs)
images = get_random_images(options.directory, len(outputs) + options.extra_images)
wallpaper_parts = get_best_parts(candidates, images)
for part in wallpaper_parts:
logger.info("wallpaper: {} ({}×{})".format(
part.image.filename[(len(options.directory) + 1) :],
*part.image.size
))
build(background, wallpaper_parts)
save(background, options.target, options.compression)
except Exception as e:
logger.exception("%s", e)
sys.exit(1)
sys.exit(0)