134 lines
5.6 KiB
Python
134 lines
5.6 KiB
Python
# Makes the small (16x16) and tiny (8x8) icon sheets from the full 24x24 weapon and trinket icon sheets, with an anm2
|
|
# for each listing the same regions and animations at the new size.
|
|
#
|
|
# The icons are a black body with grey detail inside a one pixel white outline, all tinted when drawn. Shrinking that
|
|
# outline along with the rest would smear it, so only the body is shrunk (a cell is body where enough of what it covers
|
|
# was, grey where most of that was grey) and a fresh one pixel outline is drawn around it at the new size. Tiny icons
|
|
# have no room for that, so they're the shrunk silhouette in white, keeping black only where it's thick enough.
|
|
#
|
|
# python tools/icons_resize.py
|
|
|
|
import json
|
|
import pathlib
|
|
|
|
from PIL import Image
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
GFX = ROOT / "resources" / "gfx"
|
|
SHEETS = ["weapon_icons", "trinkets"]
|
|
SIZES = {"small": 16, "tiny": 8}
|
|
CELL = 24
|
|
BODY_COVERAGE = 0.4
|
|
SHAPE_COVERAGE = 0.5
|
|
INNER_COVERAGE = 0.6
|
|
WHITE = (255, 255, 255, 255)
|
|
GREY = (128, 128, 128, 255)
|
|
BLACK = (0, 0, 0, 255)
|
|
|
|
|
|
def is_body(pixel):
|
|
return pixel[3] > 0 and pixel[:3] != (255, 255, 255)
|
|
|
|
|
|
def coverage_get(source, x, y, scale):
|
|
"""How much of a cell at the new size was opaque, body, and grey body, as shares of the cell."""
|
|
opaque = covered = grey = total = 0.0
|
|
for sy in range(int(y * scale), int(-(-(y + 1) * scale // 1))):
|
|
for sx in range(int(x * scale), int(-(-(x + 1) * scale // 1))):
|
|
# How much of the source pixel falls inside this cell.
|
|
weight = (min(sx + 1, (x + 1) * scale) - max(sx, x * scale)) * \
|
|
(min(sy + 1, (y + 1) * scale) - max(sy, y * scale))
|
|
total += weight
|
|
pixel = source[sx, sy]
|
|
opaque += weight * (pixel[3] > 0)
|
|
if is_body(pixel):
|
|
covered += weight
|
|
grey += weight * (pixel[:3] == (128, 128, 128))
|
|
return opaque / total, covered / total, grey / total
|
|
|
|
|
|
def icon_tiny(icon, size):
|
|
"""Too small for an outline around a body: the whole silhouette in white, with black (or grey) left only where it's
|
|
thick enough to keep a white rim all round."""
|
|
source = icon.load()
|
|
scale = CELL / size
|
|
cells = [[coverage_get(source, x, y, scale) for x in range(size)] for y in range(size)]
|
|
shape = [[cells[y][x][0] >= SHAPE_COVERAGE for x in range(size)] for y in range(size)]
|
|
result = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
pixels = result.load()
|
|
for y in range(size):
|
|
for x in range(size):
|
|
if not shape[y][x]:
|
|
continue
|
|
opaque, covered, grey = cells[y][x]
|
|
inner = all(0 <= x + dx < size and 0 <= y + dy < size and shape[y + dy][x + dx]
|
|
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)))
|
|
if inner and covered >= INNER_COVERAGE:
|
|
pixels[x, y] = GREY if grey > covered / 2 else BLACK
|
|
else:
|
|
pixels[x, y] = WHITE
|
|
return result
|
|
|
|
|
|
def icon_shrink(icon, size):
|
|
source = icon.load()
|
|
scale = CELL / size
|
|
body = [[None] * size for _ in range(size)]
|
|
for y in range(size):
|
|
for x in range(size):
|
|
covered = grey = total = 0.0
|
|
for sy in range(int(y * scale), int(-(-(y + 1) * scale // 1))):
|
|
for sx in range(int(x * scale), int(-(-(x + 1) * scale // 1))):
|
|
# How much of the source pixel falls inside this cell.
|
|
weight = (min(sx + 1, (x + 1) * scale) - max(sx, x * scale)) * \
|
|
(min(sy + 1, (y + 1) * scale) - max(sy, y * scale))
|
|
total += weight
|
|
pixel = source[sx, sy]
|
|
if is_body(pixel):
|
|
covered += weight
|
|
grey += weight * (pixel[:3] == (128, 128, 128))
|
|
if covered / total >= BODY_COVERAGE:
|
|
body[y][x] = GREY if grey > covered / 2 else BLACK
|
|
|
|
result = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
pixels = result.load()
|
|
for y in range(size):
|
|
for x in range(size):
|
|
if body[y][x]:
|
|
pixels[x, y] = body[y][x]
|
|
continue
|
|
touching = any(0 <= x + dx < size and 0 <= y + dy < size and body[y + dy][x + dx]
|
|
for dx in (-1, 0, 1) for dy in (-1, 0, 1))
|
|
if touching:
|
|
pixels[x, y] = WHITE
|
|
return result
|
|
|
|
|
|
def main():
|
|
for sheet in SHEETS:
|
|
art = json.loads((GFX / f"{sheet}.anm2").read_text())
|
|
image = Image.open(GFX / f"{sheet}.png").convert("RGBA")
|
|
for suffix, size in SIZES.items():
|
|
columns, rows = image.width // CELL, image.height // CELL
|
|
result = Image.new("RGBA", (columns * size, rows * size), (0, 0, 0, 0))
|
|
for row in range(rows):
|
|
for column in range(columns):
|
|
icon = image.crop((column * CELL, row * CELL, (column + 1) * CELL, (row + 1) * CELL))
|
|
shrink = icon_tiny if suffix == "tiny" else icon_shrink
|
|
result.paste(shrink(icon, size), (column * size, row * size))
|
|
name = f"{sheet}_{suffix}"
|
|
result.save(GFX / f"{name}.png")
|
|
|
|
shrunk = json.loads(json.dumps(art))
|
|
spritesheet = shrunk["spritesheets"][0]
|
|
spritesheet["path"] = f"{name}.png"
|
|
for region in spritesheet["regions"]:
|
|
region["crop"] = [region["crop"][0] // CELL * size, region["crop"][1] // CELL * size]
|
|
region["size"] = [size, size]
|
|
(GFX / f"{name}.anm2").write_text(json.dumps(shrunk, indent=2) + "\n")
|
|
print(f"{name}: {result.width}x{result.height}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|