From dc8d31471372a58081990637146f5d826b1c7a52 Mon Sep 17 00:00:00 2001 From: bobloy Date: Tue, 11 Aug 2020 17:23:42 -0400 Subject: [PATCH] Start of map maker, ambiguous file format (probably just jpg and png) --- conquest/conquest.py | 178 +++++++++++++++++++------- conquest/data/assets/maps.json | 4 +- conquest/data/assets/simple/data.json | 3 +- conquest/info.json | 2 +- conquest/regioner.py | 132 +++++++++++++++++++ 5 files changed, 267 insertions(+), 52 deletions(-) create mode 100644 conquest/regioner.py diff --git a/conquest/conquest.py b/conquest/conquest.py index 609f8b4..a65a369 100644 --- a/conquest/conquest.py +++ b/conquest/conquest.py @@ -1,17 +1,35 @@ +import asyncio import json import os import pathlib +from abc import ABC from shutil import copyfile from typing import Optional import discord -from PIL import Image, ImageColor, ImageOps +from PIL import Image, ImageChops, ImageColor, ImageOps from discord.ext.commands import Greedy from redbot.core import Config, commands from redbot.core.bot import Red from redbot.core.data_manager import bundled_data_path, cog_data_path +# class StrList(commands.Converter): +# async def convert(self, ctx, argument): +# x = argument.split() +# +# out_ints = set() +# for a in x: +# if "-" in a: +# splitted = a.split("-", 1) +# first = int(splitted[0]) +# last = int(splitted[1]) +# add_range = {b for b in range(first, last+1)} +# +# else: +# out_ints.add(int(a)) + + class Conquest(commands.Cog): """ Cog Description @@ -38,18 +56,44 @@ class Conquest(commands.Cog): self.current_map = None self.map_data = None + self.ext = None + self.ext_format = None async def red_delete_data_for_user(self, **kwargs): """Nothing to delete""" return async def load_data(self): + """ + Initial loading of data from bundled_data_path and config + """ self.asset_path = bundled_data_path(self) / "assets" self.current_map = await self.config.current_map() + await self.current_map_load() + + async def current_map_load(self): map_data_path = self.asset_path / self.current_map / "data.json" with map_data_path.open() as mapdata: - self.map_data = json.load(mapdata) + self.map_data: dict = json.load(mapdata) + self.ext = self.map_data["extension"] + self.ext_format = "JPEG" if self.ext.upper() == "JPG" else self.ext.upper() + + @commands.group() + async def mapmaker(self, ctx: commands.context): + """ + Base command for managing current maps or creating new ones + """ + if ctx.invoked_subcommand is None: + pass + + @mapmaker.command(name="load") + async def _mapmaker_load(self, ctx: commands.Context, map_path=""): + """Load a map image to be modified. Upload one with this command or provide a path""" + message: discord.Message = ctx.message + if not message.attachments and not map_path: + await ctx.maybe_send_embed("Either upload an image with this command or provide a path to the image") + return @commands.group() async def conquest(self, ctx: commands.Context): @@ -57,7 +101,8 @@ class Conquest(commands.Cog): Base command for conquest cog. Start with `[p]conquest set map` to select a map. """ if ctx.invoked_subcommand is None: - pass # TODO: Print current map probably + if self.current_map is not None: + await self._conquest_current(ctx) @conquest.command(name="list") async def _conquest_list(self, ctx: commands.Context): @@ -144,10 +189,10 @@ class Conquest(commands.Cog): return zoomed_path = await self._create_zoomed_map( - self.data_path / self.current_map / "current.jpg", x, y, zoom + self.data_path / self.current_map / f"current.{self.ext}", x, y, zoom ) - await ctx.send(file=discord.File(fp=zoomed_path, filename="current_zoomed.jpg",)) + await ctx.send(file=discord.File(fp=zoomed_path, filename=f"current_zoomed.{self.ext}",)) async def _create_zoomed_map(self, map_path, x, y, zoom, **kwargs): current_map = Image.open(map_path) @@ -156,8 +201,8 @@ class Conquest(commands.Cog): zoom2 = zoom * 2 zoomed_map = current_map.crop((x - w / zoom2, y - h / zoom2, x + w / zoom2, y + h / zoom2)) # zoomed_map = zoomed_map.resize((w, h), Image.LANCZOS) - zoomed_map.save(self.data_path / self.current_map / "zoomed.jpg", "jpeg") - return self.data_path / self.current_map / "zoomed.jpg" + zoomed_map.save(self.data_path / self.current_map / f"zoomed.{self.ext}", self.ext_format) + return self.data_path / self.current_map / f"zoomed.{self.ext}" @conquest_set.command(name="save") async def _conquest_set_save(self, ctx: commands.Context, *, save_name): @@ -167,13 +212,13 @@ class Conquest(commands.Cog): return current_map_folder = self.data_path / self.current_map - current_map = current_map_folder / "current.jpg" + current_map = current_map_folder / f"current.{self.ext}" if not current_map_folder.exists() or not current_map.exists(): await ctx.maybe_send_embed("Current map doesn't exist! Try setting a new one") return - copyfile(current_map, current_map_folder / f"{save_name}.jpg") + copyfile(current_map, current_map_folder / f"{save_name}.{self.ext}") await ctx.tick() @conquest_set.command(name="load") @@ -184,8 +229,8 @@ class Conquest(commands.Cog): return current_map_folder = self.data_path / self.current_map - current_map = current_map_folder / "current.jpg" - saved_map = current_map_folder / f"{save_name}.jpg" + current_map = current_map_folder / f"current.{self.ext}" + saved_map = current_map_folder / f"{save_name}.{self.ext}" if not current_map_folder.exists() or not saved_map.exists(): await ctx.maybe_send_embed(f"Saved map not found in the {self.current_map} folder") @@ -211,12 +256,16 @@ class Conquest(commands.Cog): self.current_map = mapname await self.config.current_map.set(self.current_map) # Save to config too - map_data_path = self.asset_path / mapname / "data.json" - with map_data_path.open() as mapdata: - self.map_data = json.load(mapdata) + await self.current_map_load() + + # map_data_path = self.asset_path / mapname / "data.json" + # with map_data_path.open() as mapdata: + # self.map_data = json.load(mapdata) + # + # self.ext = self.map_data["extension"] current_map_folder = self.data_path / self.current_map - current_map = current_map_folder / "current.jpg" + current_map = current_map_folder / f"current.{self.ext}" if not reset and current_map.exists(): await ctx.maybe_send_embed( @@ -226,7 +275,7 @@ class Conquest(commands.Cog): else: if not current_map_folder.exists(): os.makedirs(current_map_folder) - copyfile(self.asset_path / mapname / "blank.jpg", current_map) + copyfile(self.asset_path / mapname / f"blank.{self.ext}", current_map) await ctx.tick() @@ -239,9 +288,9 @@ class Conquest(commands.Cog): await ctx.maybe_send_embed("No map is currently set. See `[p]conquest set map`") return - current_jpg = self.data_path / self.current_map / "current.jpg" + current_img = self.data_path / self.current_map / f"current.{self.ext}" - await self._send_maybe_zoomed_map(ctx, current_jpg, "current_map.jpg") + await self._send_maybe_zoomed_map(ctx, current_img, f"current_map.{self.ext}") async def _send_maybe_zoomed_map(self, ctx, map_path, filename): zoom_data = {"enabled": False} @@ -266,10 +315,9 @@ class Conquest(commands.Cog): await ctx.maybe_send_embed("No map is currently set. See `[p]conquest set map`") return - current_blank_jpg = self.asset_path / self.current_map / "blank.jpg" + current_blank_img = self.asset_path / self.current_map / f"blank.{self.ext}" - await self._send_maybe_zoomed_map(ctx, current_blank_jpg, "blank_map.jpg") - # await ctx.send(file=discord.File(fp=current_blank_jpg, filename="blank_map.jpg")) + await self._send_maybe_zoomed_map(ctx, current_blank_img, f"blank_map.{self.ext}") @conquest.command("numbered") async def _conquest_numbered(self, ctx: commands.Context): @@ -280,35 +328,66 @@ class Conquest(commands.Cog): await ctx.maybe_send_embed("No map is currently set. See `[p]conquest set map`") return - numbers_path = self.asset_path / self.current_map / "numbers.jpg" + numbers_path = self.asset_path / self.current_map / f"numbers.{self.ext}" if not numbers_path.exists(): await ctx.send( file=discord.File( - fp=self.asset_path / self.current_map / "numbered.jpg", - filename="numbered.jpg", + fp=self.asset_path / self.current_map / f"numbered.{self.ext}", + filename=f"numbered.{self.ext}", ) ) return - current_map = Image.open(self.data_path / self.current_map / "current.jpg") + current_map = Image.open(self.data_path / self.current_map / f"current.{self.ext}") numbers = Image.open(numbers_path).convert("L") inverted_map = ImageOps.invert(current_map) - current_numbered_jpg: Image.Image = Image.composite(current_map, inverted_map, numbers) - current_numbered_jpg.save( - self.data_path / self.current_map / "current_numbered.jpg", "jpeg" + loop = asyncio.get_running_loop() + current_numbered_img = await loop.run_in_executor( + None, Image.composite, current_map, inverted_map, numbers + ) + + current_numbered_img.save( + self.data_path / self.current_map / f"current_numbered.{self.ext}", self.ext_format ) await self._send_maybe_zoomed_map( - ctx, self.data_path / self.current_map / "current_numbered.jpg", "current_numbered.jpg" + ctx, + self.data_path / self.current_map / f"current_numbered.{self.ext}", + f"current_numbered.{self.ext}", ) - # await ctx.send( - # file=discord.File( - # fp=self.data_path / self.current_map / "current_numbered.jpg", - # filename="current_numbered.jpg", - # ) - # ) + + @conquest.command(name="multitake") + async def _conquest_multitake( + self, ctx: commands.Context, start_region: int, end_region: int, color: str + ): + if self.current_map is None: + await ctx.maybe_send_embed("No map is currently set. See `[p]conquest set map`") + return + + try: + color = ImageColor.getrgb(color) + except ValueError: + await ctx.maybe_send_embed(f"Invalid color {color}") + return + + if end_region > self.map_data["region_max"] or start_region < 1: + await ctx.maybe_send_embed( + f"Max region number is {self.map_data['region_max']}, minimum is 1" + ) + return + regions = [r for r in range(start_region, end_region + 1)] + + await self._process_take_regions(color, ctx, regions) + + async def _process_take_regions(self, color, ctx, regions): + current_img_path = self.data_path / self.current_map / f"current.{self.ext}" + im = Image.open(current_img_path) + async with ctx.typing(): + out: Image.Image = await self._composite_regions(im, regions, color) + out.save(current_img_path, self.ext_format) + await self._send_maybe_zoomed_map(ctx, current_img_path, f"map.{self.ext}") @conquest.command(name="take") async def _conquest_take(self, ctx: commands.Context, regions: Greedy[int], *, color: str): @@ -337,27 +416,28 @@ class Conquest(commands.Cog): await ctx.maybe_send_embed( f"Max region number is {self.map_data['region_max']}, minimum is 1" ) + return - current_jpg_path = self.data_path / self.current_map / "current.jpg" - im = Image.open(current_jpg_path) - out: Image.Image = await self._composite_regions(im, regions, color) - - out.save(current_jpg_path, "jpeg") - - await self._send_maybe_zoomed_map(ctx, current_jpg_path, "map.jpg") - # await ctx.send(file=discord.File(fp=current_jpg_path, filename="map.jpg")) + await self._process_take_regions(color, ctx, regions) async def _composite_regions(self, im, regions, color) -> Image.Image: - im2 = Image.new("RGB", im.size, color) - out = None + loop = asyncio.get_running_loop() + + combined_mask = None for region in regions: mask = Image.open( - self.asset_path / self.current_map / "masks" / f"{region}.jpg" + self.asset_path / self.current_map / "masks" / f"{region}.{self.ext}" ).convert("L") - if out is None: - out = Image.composite(im, im2, mask) + if combined_mask is None: + combined_mask = mask else: - out = Image.composite(out, im2, mask) + # combined_mask = ImageChops.logical_or(combined_mask, mask) + combined_mask = await loop.run_in_executor( + None, ImageChops.multiply, combined_mask, mask + ) + + out = await loop.run_in_executor(None, Image.composite, im, im2, combined_mask) + return out diff --git a/conquest/data/assets/maps.json b/conquest/data/assets/maps.json index 96f1418..a7d1c03 100644 --- a/conquest/data/assets/maps.json +++ b/conquest/data/assets/maps.json @@ -1,5 +1,7 @@ { "maps": [ - "simple_blank_map" + "simple_blank_map", + "test", + "test2" ] } \ No newline at end of file diff --git a/conquest/data/assets/simple/data.json b/conquest/data/assets/simple/data.json index 9baa7fa..56a4c69 100644 --- a/conquest/data/assets/simple/data.json +++ b/conquest/data/assets/simple/data.json @@ -1,3 +1,4 @@ { - "region_max": 70 + "region_max": 70, + "extension": "jpg" } \ No newline at end of file diff --git a/conquest/info.json b/conquest/info.json index b83fc85..a8cb171 100644 --- a/conquest/info.json +++ b/conquest/info.json @@ -12,7 +12,7 @@ "install_msg": "Thank you for installing Dad. Get started with `[p]load conquest`, then `[p]help Conquest`", "short": "War Game Map", "requirements": [ - "pillow" + "Pillow" ], "tags": [ "bobloy", diff --git a/conquest/regioner.py b/conquest/regioner.py new file mode 100644 index 0000000..dc77373 --- /dev/null +++ b/conquest/regioner.py @@ -0,0 +1,132 @@ +import os +import pathlib +from PIL import Image, ImageColor, ImageFont, ImageOps, ImageDraw +from PIL.ImageDraw import _color_diff + + +def get_center(points): + """ + Taken from https://stackoverflow.com/questions/4355894/how-to-get-center-of-set-of-points-using-python + """ + x = [p[0] for p in points] + y = [p[1] for p in points] + return sum(x) / len(points), sum(y) / len(points) + + +def floodfill(image, xy, value, border=None, thresh=0) -> set: + """ + Taken and modified from PIL.ImageDraw.floodfill + + (experimental) Fills a bounded region with a given color. + + :param image: Target image. + :param xy: Seed position (a 2-item coordinate tuple). See + :ref:`coordinate-system`. + :param value: Fill color. + :param border: Optional border value. If given, the region consists of + pixels with a color different from the border color. If not given, + the region consists of pixels having the same color as the seed + pixel. + :param thresh: Optional threshold value which specifies a maximum + tolerable difference of a pixel value from the 'background' in + order for it to be replaced. Useful for filling regions of + non-homogeneous, but similar, colors. + """ + # based on an implementation by Eric S. Raymond + # amended by yo1995 @20180806 + pixel = image.load() + x, y = xy + try: + background = pixel[x, y] + if _color_diff(value, background) <= thresh: + return set() # seed point already has fill color + pixel[x, y] = value + except (ValueError, IndexError): + return set() # seed point outside image + edge = {(x, y)} + # use a set to keep record of current and previous edge pixels + # to reduce memory consumption + filled_pixels = set() + full_edge = set() + while edge: + filled_pixels.update(edge) + new_edge = set() + for (x, y) in edge: # 4 adjacent method + for (s, t) in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): + # If already processed, or if a coordinate is negative, skip + if (s, t) in full_edge or s < 0 or t < 0: + continue + try: + p = pixel[s, t] + except (ValueError, IndexError): + pass + else: + full_edge.add((s, t)) + if border is None: + fill = _color_diff(p, background) <= thresh + else: + fill = p != value and p != border + if fill: + pixel[s, t] = value + new_edge.add((s, t)) + full_edge = edge # discard pixels processed + edge = new_edge + return filled_pixels + + +class Regioner: + def __init__( + self, filepath: pathlib.Path, filename: str, wall_color="black", region_color="white" + ): + self.filepath = filepath + self.filename = filename + self.wall_color = ImageColor.getcolor(wall_color, "L") + self.region_color = ImageColor.getcolor(region_color, "L") + + def execute(self): + base_img_path = self.filepath / self.filename + if not base_img_path.exists(): + return None + + masks_path = self.filepath / "masks" + + if not masks_path.exists(): + os.makedirs(masks_path) + + black = ImageColor.getcolor("black", "L") + white = ImageColor.getcolor("white", "L") + + base_img: Image.Image = Image.open(base_img_path).convert("L") + already_processed = set() + + mask_count = 0 + mask_centers = {} + + for y1 in range(base_img.height): + for x1 in range(base_img.width): + if (x1, y1) in already_processed: + continue + if base_img.getpixel((x1, y1)) == self.region_color: + filled = floodfill(base_img, (x1, y1), black, self.wall_color) + if filled: # Pixels were updated, make them into a mask + mask = Image.new("L", base_img.size, 255) + for x2, y2 in filled: + mask.putpixel((x2, y2), 0) + + mask_count += 1 + mask = mask.convert("L") + mask.save(masks_path / f"{mask_count}.png", "PNG") + + mask_centers[mask_count] = get_center(filled) + + already_processed.update(filled) + + number_img = Image.new("L", base_img.size, 255) + fnt = ImageFont.load_default() + d = ImageDraw.Draw(number_img) + for mask_num, center in mask_centers.items(): + d.text(center, str(mask_num), font=fnt, fill=0) + + number_img.save(self.filepath / f"numbers.png", "PNG") + + return mask_centers