Files
ausboss-ComfyUI-AusBoss/tests/test_compare_erase.py
ausboss 8837c75553 Keep see-through parts in Crop/Stitch Inpaint and Image Compare
Stitch Inpaint takes an RGB patch over an RGBA picture and keeps the
picture's own alpha, instead of stopping on a channel mismatch. The halo
fix spreads colours only.

Image Compare keeps alpha in a preview that has see-through parts and
flags it, so the panel shows them over a checkerboard (Slide, A/B and
Erase). The Erase mix lays the top picture over the other, the saved PNG
is RGBA, and the PSD carries each layer's transparency plus a merged
alpha, its flat colours stored over white as Photoshop does. A see-through
B is resized premultiplied. Align measures over gray and warps all four
channels. Solid pictures give byte-identical files.

Refs #232
2026-10-26 18:40:49 -05:00

592 lines
30 KiB
Python

"""Image Compare A/B's Erase mix: the strokes, the mask, the saved files."""
from __future__ import annotations
import re
import struct
import sys
import tempfile
import unittest
from pathlib import Path
import numpy as np
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
del sys.modules["nodes"]
from nodes import _compare_helpers as helpers
from nodes._compare_helpers import (
ALIGN_FAILED,
CompareSaveError,
align_pair,
brush_core,
brush_sigma,
clean_save_name,
clean_strokes,
erase_mask,
mix_pictures,
pair_pictures,
remember_pair,
save_mix,
tidy_save_name,
)
from nodes._psd_helpers import write_psd
A_NAME = "ausboss_compare_abcde_00001_.png"
B_NAME = "ausboss_compare_abcde_00002_.png"
SOURCES = [ROOT / "nodes" / "node_compare.py", ROOT / "nodes" / "_compare_helpers.py"]
def stroke(points, size=40, soft=0, erase=1):
return {"o": erase, "d": size, "s": soft, "p": [value for point in points for value in point]}
def read_psd(path: Path) -> dict:
"""The parts of a PSD the tests check, read straight from its bytes."""
data = path.read_bytes()
signature, version, channels, height, width, depth, mode = struct.unpack(">4sH6xHIIHH", data[:26])
at = 26
at += 4 + struct.unpack(">I", data[at:at + 4])[0] # colour mode data
at += 4 + struct.unpack(">I", data[at:at + 4])[0] # image resources
block = struct.unpack(">I", data[at:at + 4])[0]
end = at + 4 + block
info, count = struct.unpack(">Ih", data[at + 4:at + 10])
# A negative count: the flat picture's last channel is its alpha.
merged_alpha, count = count < 0, abs(count)
at += 10
layers = []
for _ in range(count):
top, left, bottom, right, planes = struct.unpack(">4iH", data[at:at + 18])
at += 18
ids = []
for _ in range(planes):
channel, length = struct.unpack(">hI", data[at:at + 6])
ids.append((channel, length))
at += 6
blend, opacity, _clip, flags = data[at + 4:at + 8], data[at + 8], data[at + 9], data[at + 10]
at += 12
extra = struct.unpack(">I", data[at:at + 4])[0]
body = data[at + 4:at + 4 + extra]
at += 4 + extra
mask_bytes = struct.unpack(">I", body[:4])[0]
ranges = struct.unpack(">I", body[4 + mask_bytes:8 + mask_bytes])[0]
name_at = 8 + mask_bytes + ranges
name = body[name_at + 1:name_at + 1 + body[name_at]].decode("ascii")
layers.append({"box": (top, left, bottom, right), "ids": ids, "blend": blend, "opacity": opacity,
"flags": flags, "has_mask": mask_bytes == 20, "name": name})
planes = {}
for layer in layers:
for channel, length in layer["ids"]:
assert data[at:at + 2] == b"\x00\x00"
planes[(layer["name"], channel)] = np.frombuffer(data[at + 2:at + length], dtype=np.uint8).reshape(height, width)
at += length
flat_at = end
assert data[flat_at:flat_at + 2] == b"\x00\x00"
flat = np.frombuffer(data[flat_at + 2:], dtype=np.uint8).reshape(channels, height, width).transpose(1, 2, 0)
return {"signature": signature, "version": version, "channels": channels, "size": (width, height),
"depth": depth, "mode": mode, "layers": layers, "planes": planes, "flat": flat,
"merged_alpha": merged_alpha, "layer_info": info, "bytes": len(data)}
class BrushTests(unittest.TestCase):
def test_the_browser_and_the_server_share_the_brush(self):
source = (ROOT / "js" / "shared" / "compare_erase.mjs").read_text(encoding="utf-8")
def number(name):
return int(re.search(rf"export const {name} = (\d+);", source).group(1))
low, high = re.search(r"export const ERASE_SIZE = \[(\d+), (\d+)\];", source).groups()
self.assertEqual((int(low), int(high)), helpers.ERASE_SIZE)
self.assertEqual(number("ERASE_MAX_STROKES"), helpers.ERASE_MAX_STROKES)
self.assertEqual(number("ERASE_MAX_STROKE_POINTS"), helpers.ERASE_MAX_STROKE_POINTS)
self.assertEqual(number("ERASE_MAX_POINTS"), helpers.ERASE_MAX_POINTS)
self.assertEqual(number("ERASE_OUTSIDE"), helpers.ERASE_OUTSIDE)
# The brush itself is the pack's shared one.
brush = (ROOT / "js" / "shared" / "brush.mjs").read_text(encoding="utf-8")
self.assertIn("return (size / 2) * (1 - soft / 2);", brush)
self.assertIn("return (soft * size) / 8;", brush)
self.assertIn(f'export const SAVE_NAME = "{helpers.SAVE_NAME}";', source)
self.assertIn(f"export const SAVE_NAME_MAX = {helpers.SAVE_NAME_MAX};", source)
self.assertEqual(brush_core(100, 0), 50)
self.assertEqual(brush_core(100, 1), 25)
self.assertEqual(brush_sigma(100, 0), 0)
self.assertEqual(brush_sigma(100, 1), 12.5)
def test_strokes_are_checked_and_held_to_the_limits(self):
cleaned = clean_strokes([{"o": 0, "d": 99999, "s": 250, "p": [-99999, 5, 10.5, 99999]}], 64, 48)
self.assertEqual(cleaned, [{
"erase": False, "size": 2000.0, "soft": 1.0,
"points": [(-4096.0, 5.0), (10.5, 48.0 + 4096)],
}])
self.assertEqual(clean_strokes(None, 8, 8), [])
for bad in (
"paint",
[7],
[{"d": 10, "p": [1]}],
[{"d": 10, "p": [1, 2, 3]}],
[{"d": 10, "p": [1, "2"]}],
[{"d": 10, "p": [1, float("nan")]}],
[{"d": "big", "p": [1, 2]}],
[{"d": True, "p": [1, 2]}],
[{"d": 10, "p": [0, 0] * (helpers.ERASE_MAX_STROKE_POINTS + 1)}],
[{"d": 10, "p": [0, 0]}] * (helpers.ERASE_MAX_STROKES + 1),
):
with self.assertRaises(CompareSaveError):
clean_strokes(bad, 64, 64)
full = [{"d": 10, "p": [0, 0] * helpers.ERASE_MAX_STROKE_POINTS}] * (helpers.ERASE_MAX_POINTS // helpers.ERASE_MAX_STROKE_POINTS + 1)
with self.assertRaises(CompareSaveError):
clean_strokes(full, 64, 64)
class MaskTests(unittest.TestCase):
def mask(self, strokes, width=200, height=160):
return erase_mask(clean_strokes(strokes, width, height), width, height)
def test_a_hard_dab_is_a_disc_of_the_brush_size(self):
gone = self.mask([stroke([(100, 80)], size=60)])
self.assertEqual(gone.shape, (160, 200))
self.assertAlmostEqual(float(gone.sum()), np.pi * 30 * 30, delta=4)
self.assertEqual(gone[80, 100], 1.0)
self.assertEqual(gone[80, 135], 0.0)
self.assertEqual(gone[10, 10], 0.0)
def test_a_stroke_covers_the_whole_line(self):
gone = self.mask([stroke([(40, 80), (160, 80)], size=20)])
self.assertTrue((gone[75:85, 40:160] == 1.0).all())
# Round ends, flat sides.
self.assertAlmostEqual(float(gone.sum()), 120 * 20 + np.pi * 100, delta=4)
def test_a_soft_brush_is_half_gone_at_the_rim_of_its_middle(self):
gone = self.mask([stroke([(20, 80), (180, 80)], size=80, soft=100)])
core = brush_core(80, 1.0) # 20
self.assertGreater(gone[80, 100], 0.93)
self.assertAlmostEqual(float(gone[80 - int(core), 100]), 0.5, delta=0.06)
self.assertLess(gone[80 - 40, 100], 0.04)
# It falls away steadily from the middle out.
column = gone[40:81, 100]
self.assertTrue((np.diff(column) >= -1e-6).all())
def test_bringing_back_undoes_rubbing_away(self):
both = [stroke([(100, 80)], size=120), stroke([(100, 80)], size=60, erase=0)]
gone = self.mask(both)
self.assertEqual(gone[80, 100], 0.0)
self.assertEqual(gone[80, 100 + 45], 1.0)
self.assertEqual(self.mask(list(reversed(both)))[80, 100], 1.0)
def test_a_stroke_along_the_edge_reaches_the_edge(self):
# The brush runs off the picture: the edge row is as gone as the middle.
gone = self.mask([stroke([(-50, 0), (250, 0)], size=60, soft=100)])
self.assertGreater(gone[0, 100], 0.93)
self.assertGreater(gone[0, 0], 0.93)
outside = self.mask([stroke([(-500, -500)], size=40)])
self.assertEqual(float(outside.sum()), 0.0)
def test_the_mix_takes_each_picture_where_the_mask_says(self):
top = np.full((4, 4, 3), 200, dtype=np.uint8)
under = np.full((4, 4, 3), 100, dtype=np.uint8)
gone = np.zeros((4, 4), dtype=np.float32)
gone[0, 0] = 1.0
gone[0, 1] = 0.5
mixed = mix_pictures(top, under, gone)
self.assertEqual(mixed.dtype, np.uint8)
self.assertEqual(mixed[0, 0].tolist(), [100, 100, 100])
self.assertEqual(mixed[0, 1].tolist(), [150, 150, 150])
self.assertEqual(mixed[3, 3].tolist(), [200, 200, 200])
class SaveTests(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.addCleanup(self.folder.cleanup)
self.temp = Path(self.folder.name) / "temp"
self.output = Path(self.folder.name) / "output"
self.temp.mkdir()
self.output.mkdir()
self.a = np.zeros((90, 120, 3), dtype=np.uint8)
self.a[..., 0] = 220
self.b = np.zeros((90, 120, 3), dtype=np.uint8)
self.b[..., 2] = 180
Image.fromarray(self.a).save(self.temp / A_NAME)
Image.fromarray(self.b).save(self.temp / B_NAME)
def save(self, **changes):
options = {
"picture_a": self.temp / A_NAME, "picture_b": self.temp / B_NAME, "output_dir": self.output,
"strokes": [stroke([(60, 45)], size=40)],
}
options.update(changes)
return save_mix(**options)
def test_a_run_is_named_by_a_key_and_never_by_a_file(self):
key = remember_pair(self.temp / A_NAME, self.temp / B_NAME)
self.assertEqual(pair_pictures(key), (self.temp / A_NAME, self.temp / B_NAME, ""))
named = remember_pair(self.temp / A_NAME, self.temp / B_NAME, "photo 12")
self.assertEqual(pair_pictures(named)[2], "photo 12")
self.assertNotIn("ausboss_compare", key)
self.assertNotEqual(key, remember_pair(self.temp / A_NAME, self.temp / B_NAME))
# Anything that is not a key the server gave out is "run again": a
# file name, a path, a number, nothing at all.
for wrong in (None, 7, "", "nope", A_NAME, str(self.temp / A_NAME), "../" + A_NAME, {"filename": A_NAME}, [key]):
with self.assertRaises(CompareSaveError) as raised:
pair_pictures(wrong)
self.assertEqual(raised.exception.status, 404)
self.assertIn("Run the workflow again", str(raised.exception))
# The pictures went with ComfyUI's temp folder: the same answer.
gone = remember_pair(self.temp / "missing_a.png", self.temp / B_NAME)
with self.assertRaises(CompareSaveError) as raised:
pair_pictures(gone)
self.assertEqual(raised.exception.status, 404)
def test_only_the_newest_runs_are_kept(self):
first = remember_pair(self.temp / A_NAME, self.temp / B_NAME)
for _ in range(helpers.PAIRS_KEPT):
last = remember_pair(self.temp / A_NAME, self.temp / B_NAME)
with self.assertRaises(CompareSaveError):
pair_pictures(first)
self.assertEqual(pair_pictures(last), (self.temp / A_NAME, self.temp / B_NAME, ""))
def test_the_route_takes_no_file_name_from_the_request(self):
"""The body is a key, the strokes, two choices and a name for the new
files. No file to read is named, the name goes through
clean_save_name before anything is built from it, and what the route
answers names no place on the server's disk."""
node = SOURCES[0].read_text(encoding="utf-8")
route = node[node.index("async def save(request):"):node.index("class AusBossCompare:")]
self.assertEqual(sorted(set(re.findall(r'body\.get\("(\w+)"', route))), ["layers", "name", "pair", "strokes", "top"])
self.assertIn('clean_save_name(body.get("name"))', route)
self.assertEqual(route.count('body.get("name")'), 1)
self.assertIn("safe_route_error(error)", route)
for source in SOURCES:
text = source.read_text(encoding="utf-8")
self.assertNotIn("FileResponse", text)
self.assertNotIn("rel_url", text)
save = SOURCES[1].read_text(encoding="utf-8")
save = save[save.index("def save_mix("):save.index("__all__")]
self.assertNotIn("subfolder\": subfolder", save)
for message in (helpers.PICTURES_GONE, helpers.SAVE_NAME_HELP, "Image Compare: the top picture is A or B."):
self.assertNotRegex(message, r"[\\/]")
def test_a_typed_name_is_a_plain_name_or_it_is_refused(self):
for typed, name in (
(None, "compare"), ("", "compare"), (" ", "compare"),
("mix", "mix"), (" my mix-2_final.v3 ", "my mix-2_final.v3"),
("photo.PNG", "photo"), ("Überblick 12", "Überblick 12"), ("写真", "写真"),
("x" * 80, "x" * 80),
):
self.assertEqual(clean_save_name(typed), name, typed)
for bad in (
"a/b", "..", ".", "../x", "/etc/x", "C:\\x", "a\\b", "a:b", "a\nb", "~x", "a..b", "*.png", "-x",
"x" * 81, "con<", 'q"', "a|b", "name?", 7, ["mix"], {"name": "mix"},
):
with self.assertRaises(CompareSaveError, msg=repr(bad)):
clean_save_name(bad)
# save_mix checks the name itself, whoever calls it.
with self.assertRaises(CompareSaveError):
self.save(name="../out")
with self.assertRaises(CompareSaveError):
self.save(name="sub/mix")
self.assertEqual(list(self.output.iterdir()), [])
def test_a_wired_name_is_tidied_and_never_stops_a_run(self):
for wired, name in (
("sets/a/photo 12.jpg", "photo 12"), ("C:\\pics\\shot.png", "shot"),
("../../etc/passwd", "passwd"), ("a:b*c?.webp", "a_b_c"), (" .hidden ", "hidden"),
("..", ""), ("", ""), (None, ""), (5, ""), ("///", ""),
("x" * 200 + ".png", "x" * 80),
):
tidy = tidy_save_name(wired)
self.assertEqual(tidy, name, wired)
# Whatever comes out is a name the save takes, or nothing.
self.assertEqual(clean_save_name(tidy), name or "compare")
def test_the_name_goes_into_the_file_names(self):
result = self.save(name="my mix", layers=True)
self.assertEqual([file["filename"] for file in result["files"]],
["my mix_00001.png", "my mix_00001.psd", "my mix_00001_mask.png"])
self.assertEqual({file["subfolder"] for file in result["files"]}, {""})
self.assertEqual(self.save(name="my mix")["files"][0]["filename"], "my mix_00002.png")
# Each name counts by itself.
self.assertEqual(self.save()["files"][0]["filename"], "compare_00001.png")
self.assertEqual({path.parent for path in self.output.iterdir()}, {self.output})
def test_the_mix_is_saved_as_a_png(self):
result = self.save()
self.assertEqual(result["files"], [{"filename": "compare_00001.png", "subfolder": "", "type": "output"}])
self.assertEqual((result["width"], result["height"]), (120, 90))
mixed = np.asarray(Image.open(self.output / "compare_00001.png").convert("RGB"))
self.assertEqual(mixed[45, 60].tolist(), [0, 0, 180]) # rubbed away: B shows
self.assertEqual(mixed[5, 5].tolist(), [220, 0, 0]) # untouched: A stays
self.assertEqual(sorted(path.name for path in self.output.iterdir()), ["compare_00001.png"])
# The next save takes the next number and overwrites nothing.
self.assertEqual(self.save()["files"][0]["filename"], "compare_00002.png")
def test_pictures_that_cannot_be_read_say_run_again(self):
(self.temp / A_NAME).write_bytes(b"not a picture")
with self.assertRaises(CompareSaveError) as raised:
self.save()
self.assertEqual(raised.exception.status, 404)
self.assertNotIn(str(self.temp), str(raised.exception))
self.assertEqual(list(self.output.iterdir()), [])
def test_b_on_top_swaps_the_pictures(self):
self.save(top="B")
mixed = np.asarray(Image.open(self.output / "compare_00001.png").convert("RGB"))
self.assertEqual(mixed[45, 60].tolist(), [220, 0, 0])
self.assertEqual(mixed[5, 5].tolist(), [0, 0, 180])
with self.assertRaises(CompareSaveError):
self.save(top="C")
def test_no_paint_saves_the_top_picture(self):
self.save(strokes=[])
self.assertTrue(np.array_equal(np.asarray(Image.open(self.output / "compare_00001.png").convert("RGB")), self.a))
def test_layers_add_a_psd_and_the_mask(self):
result = self.save(layers=True, strokes=[stroke([(60, 45)], size=40, soft=60)])
self.assertEqual([file["filename"] for file in result["files"]],
["compare_00001.png", "compare_00001.psd", "compare_00001_mask.png"])
with Image.open(self.output / "compare_00001.png") as png:
self.assertEqual(png.mode, "RGB") # two solid pictures make a solid mix
mixed = np.asarray(Image.open(self.output / "compare_00001.png").convert("RGB"))
mask = np.asarray(Image.open(self.output / "compare_00001_mask.png"))
self.assertEqual(mask.shape, (90, 120))
self.assertGreater(mask[45, 60], 240) # white where B shows
self.assertEqual(mask[2, 2], 0)
psd = read_psd(self.output / "compare_00001.psd")
self.assertEqual((psd["signature"], psd["version"], psd["channels"], psd["depth"], psd["mode"]), (b"8BPS", 1, 3, 8, 3))
self.assertFalse(psd["merged_alpha"])
self.assertEqual(psd["size"], (120, 90))
self.assertEqual([layer["name"] for layer in psd["layers"]], ["B", "A"]) # bottom first
under, upper = psd["layers"]
self.assertFalse(under["has_mask"])
self.assertTrue(upper["has_mask"])
self.assertEqual([channel for channel, _ in upper["ids"]], [-1, 0, 1, 2, -2])
for layer in psd["layers"]:
self.assertEqual(layer["box"], (0, 0, 90, 120))
self.assertEqual((layer["blend"], layer["opacity"], layer["flags"]), (b"norm", 255, 8))
self.assertEqual(psd["layer_info"] % 2, 0)
# Each layer is the whole picture, untouched; the paint is only in the mask.
self.assertTrue(np.array_equal(np.dstack([psd["planes"][("A", c)] for c in range(3)]), self.a))
self.assertTrue(np.array_equal(np.dstack([psd["planes"][("B", c)] for c in range(3)]), self.b))
self.assertTrue((psd["planes"][("A", -1)] == 255).all())
self.assertTrue(np.array_equal(psd["planes"][("A", -2)], 255 - mask))
self.assertTrue(np.array_equal(psd["flat"], mixed))
# Pillow reads the flat picture of the same file.
with Image.open(self.output / "compare_00001.psd") as opened:
self.assertEqual((opened.format, opened.size), ("PSD", (120, 90)))
self.assertTrue(np.array_equal(np.asarray(opened.convert("RGB")), mixed))
def test_a_b_of_another_size_is_fitted_to_a(self):
Image.fromarray(np.full((45, 60, 3), 90, dtype=np.uint8)).save(self.temp / B_NAME)
result = self.save(strokes=[stroke([(0, 0), (120, 90)], size=400)])
self.assertEqual((result["width"], result["height"]), (120, 90))
mixed = np.asarray(Image.open(self.output / "compare_00001.png").convert("RGB"))
self.assertEqual(mixed.shape, (90, 120, 3))
self.assertTrue((mixed == 90).all())
def test_the_files_have_a_fixed_name_in_the_output_folder(self):
(self.output / "compare_00007.png").write_bytes(b"")
(self.output / "other_00020.png").write_bytes(b"")
result = self.save(layers=True)
self.assertEqual(
[(file["filename"], file["subfolder"], file["type"]) for file in result["files"]],
[("compare_00008.png", "", "output"), ("compare_00008.psd", "", "output"), ("compare_00008_mask.png", "", "output")],
)
# A name that is a link out of the folder is not written through.
outside = Path(self.folder.name) / "elsewhere.png"
try:
(self.output / "compare_00009.png").symlink_to(outside)
except OSError:
return
self.assertEqual(self.save()["files"][0]["filename"], "compare_00010.png")
self.assertFalse(outside.exists())
def test_bad_strokes_stop_the_save_before_anything_is_written(self):
with self.assertRaises(CompareSaveError):
self.save(strokes=[{"d": 10, "p": [1]}])
self.assertEqual(list(self.output.iterdir()), [])
class SeeThroughTests(unittest.TestCase):
"""A picture with see-through parts keeps them in the mix and the PSD."""
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.addCleanup(self.folder.cleanup)
self.temp = Path(self.folder.name) / "temp"
self.output = Path(self.folder.name) / "output"
self.temp.mkdir()
self.output.mkdir()
# A: red, clear on the left, half clear in a strip beside that.
self.a = np.zeros((90, 120, 4), dtype=np.uint8)
self.a[..., 0] = 220
self.a[..., 3] = 255
self.a[:, :40, 3] = 0
self.a[:, 40:50, 3] = 128
# B: an edit that filled the background in, solid blue.
self.b = np.zeros((90, 120, 3), dtype=np.uint8)
self.b[..., 2] = 180
Image.fromarray(self.a).save(self.temp / A_NAME)
Image.fromarray(self.b).save(self.temp / B_NAME)
def save(self, **changes):
options = {"picture_a": self.temp / A_NAME, "picture_b": self.temp / B_NAME, "output_dir": self.output}
options.update(changes)
save_mix(**options)
return np.asarray(Image.open(self.output / "compare_00001.png"))
def test_rubbing_the_top_away_brings_back_the_clear_parts_underneath(self):
mixed = self.save(top="B", strokes=[stroke([(0, 45), (120, 45)], size=400)])
self.assertEqual(mixed.shape, (90, 120, 4))
self.assertTrue(np.array_equal(mixed[..., 3], self.a[..., 3]))
shown = self.a[..., 3] > 0
self.assertTrue(np.array_equal(mixed[shown][:, :3], self.a[shown][:, :3]))
def test_the_picture_underneath_shows_through_the_top_one(self):
mixed = self.save(top="A", strokes=[])
self.assertTrue((mixed[..., 3] == 255).all())
self.assertTrue(np.array_equal(mixed[:, :40, :3], self.b[:, :40]))
self.assertTrue(np.array_equal(mixed[:, 50:, :3], self.a[:, 50:, :3]))
self.assertTrue(np.array_equal(mixed[:, 45, :3], np.round((self.a[:, 45, :3] * 128.0 + self.b[:, 45] * 127.0) / 255.0)))
def test_the_psd_keeps_each_picture_s_clear_parts(self):
mixed = self.save(top="B", strokes=[stroke([(0, 45)], size=80)], layers=True)
psd = read_psd(self.output / "compare_00001.psd")
self.assertEqual((psd["channels"], psd["mode"]), (4, 3))
self.assertTrue(psd["merged_alpha"])
self.assertEqual([layer["name"] for layer in psd["layers"]], ["A", "B"])
self.assertTrue(np.array_equal(psd["planes"][("A", -1)], self.a[..., 3]))
self.assertTrue((psd["planes"][("B", -1)] == 255).all())
self.assertTrue(np.array_equal(np.dstack([psd["planes"][("A", c)] for c in range(3)]), self.a[..., :3]))
# The flat picture: the mix's alpha, its colours laid over white the way Photoshop keeps them.
self.assertTrue(np.array_equal(psd["flat"][..., 3], mixed[..., 3]))
weight = mixed[..., 3:] / 255.0
self.assertTrue(np.array_equal(psd["flat"][..., :3], np.round(mixed[..., :3] * weight + 255.0 * (1 - weight))))
self.assertTrue((psd["flat"][mixed[..., 3] == 0][:, :3] == 255).all())
with Image.open(self.output / "compare_00001.psd") as opened:
self.assertEqual((opened.format, opened.size), ("PSD", (120, 90)))
def test_a_see_through_b_of_another_size_is_fitted_without_its_hidden_colour(self):
# The colour under a clear pixel is not part of the picture: B fitted
# to A's size comes out the same whatever it is.
mixes = []
for hidden in (0, 255):
b = np.full((180, 240, 4), hidden, dtype=np.uint8)
b[..., 3] = 0
b[40:140, 60:180] = (30, 160, 90, 255)
Image.fromarray(b).save(self.temp / B_NAME)
mixes.append(self.save(top="B", strokes=[]))
(self.output / "compare_00001.png").unlink()
self.assertTrue(np.array_equal(mixes[0], mixes[1]))
class PsdTests(unittest.TestCase):
def test_sizes_must_agree(self):
flat = np.zeros((4, 6, 3), dtype=np.uint8)
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "x.psd"
with self.assertRaises(ValueError):
write_psd(path, [{"name": "a", "rgb": np.zeros((4, 5, 3), dtype=np.uint8)}], flat)
with self.assertRaises(ValueError):
write_psd(path, [{"name": "a", "rgb": flat, "mask": np.zeros((3, 6), dtype=np.uint8)}], flat)
with self.assertRaises(ValueError):
write_psd(path, [], flat)
write_psd(path, [{"name": "only", "rgb": flat}], flat)
self.assertEqual(read_psd(path)["layers"][0]["name"], "only")
# 26 header, 3 empty blocks' lengths, the layer block, the flat picture.
self.assertEqual(read_psd(path)["bytes"], path.stat().st_size)
class AlignTests(unittest.TestCase):
"""Align lines B up with A the way Realign to Source measures an edit."""
SAMPLE = ROOT / "example_workflows" / "inputs" / "ausboss_pier_sunrise.png"
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.temp = Path(self.folder.name)
with Image.open(self.SAMPLE) as image:
self.picture = image.convert("RGB").resize((416, 608), Image.LANCZOS)
self.a = self.temp / A_NAME
self.picture.save(self.a)
def tearDown(self):
self.folder.cleanup()
def b_from(self, image):
path = self.temp / B_NAME
image.save(path)
return path
def moved(self):
"""What an edit model often does: the same picture, 2% bigger and a little to the side."""
width, height = self.picture.size
big = self.picture.resize((round(width * 1.02), round(height * 1.02)), Image.LANCZOS)
left, top = (big.width - width) // 2 - 6, (big.height - height) // 2 + 4
return big.crop((left, top, left + width, top + height))
def test_a_moved_and_zoomed_b_comes_back_lined_up(self):
width, height = self.picture.size
b = self.b_from(self.moved())
before = np.abs(np.asarray(Image.open(b), dtype=np.float32) - np.asarray(self.picture, dtype=np.float32)).mean()
result = align_pair(picture_a=self.a, picture_b=b, temp_root=self.temp, name="pier")
self.assertTrue(result["aligned"])
self.assertRegex(result["message"], r"^B now lines up with A\. It was off by up to \d+ px\.$")
self.assertEqual(result["file"].parent, self.temp)
self.assertEqual((result["width"], result["height"]), (width, height))
lined = np.asarray(Image.open(result["file"]), dtype=np.float32)
inner = (slice(40, -40), slice(40, -40))
after = np.abs(lined[inner] - np.asarray(self.picture, dtype=np.float32)[inner]).mean()
self.assertLess(after, before / 3)
# The new key stands for A with the lined-up B, and keeps the run's name.
self.assertEqual(pair_pictures(result["pair"]), (self.a, result["file"], "pier"))
self.assertFalse(result["alpha"])
def test_a_see_through_b_comes_back_lined_up_with_its_clear_parts(self):
width, height = self.picture.size
yy, xx = np.mgrid[0:height, 0:width]
disc = ((xx - width / 2) / (width * 0.4)) ** 2 + ((yy - height / 2) / (height * 0.4)) ** 2 <= 1
cutout = np.dstack([np.asarray(self.picture), np.where(disc, 255, 0).astype(np.uint8)])
Image.fromarray(cutout).save(self.a)
moved = Image.fromarray(cutout).resize((round(width * 1.02), round(height * 1.02)), Image.LANCZOS)
left, top = (moved.width - width) // 2 - 6, (moved.height - height) // 2 + 4
b = self.b_from(moved.crop((left, top, left + width, top + height)))
before = np.abs(np.asarray(Image.open(b), dtype=np.float32)[..., 3] - cutout[..., 3]).mean()
result = align_pair(picture_a=self.a, picture_b=b, temp_root=self.temp)
self.assertTrue(result["aligned"])
self.assertTrue(result["alpha"])
with Image.open(result["file"]) as lined:
self.assertEqual(lined.mode, "RGBA")
after = np.abs(np.asarray(lined, dtype=np.float32)[..., 3] - cutout[..., 3]).mean()
self.assertLess(after, before / 3)
def test_a_b_that_already_lines_up_is_left_alone(self):
b = self.b_from(self.picture)
result = align_pair(picture_a=self.a, picture_b=b, temp_root=self.temp)
self.assertEqual(result, {"aligned": False, "message": "B already lines up with A."})
self.assertEqual(sorted(path.name for path in self.temp.iterdir()), sorted([A_NAME, B_NAME]))
def test_two_different_pictures_are_left_as_they_are(self):
noise = np.random.default_rng(3).integers(0, 255, (608, 416, 3), dtype=np.uint8)
b = self.b_from(Image.fromarray(noise))
self.assertEqual(align_pair(picture_a=self.a, picture_b=b, temp_root=self.temp),
{"aligned": False, "message": ALIGN_FAILED})
def test_previews_outside_the_temp_folder_are_refused(self):
b = self.b_from(self.moved())
with tempfile.TemporaryDirectory() as other:
with self.assertRaises(CompareSaveError):
align_pair(picture_a=self.a, picture_b=b, temp_root=Path(other))
def test_gone_pictures_say_to_run_again(self):
with self.assertRaises(CompareSaveError) as caught:
align_pair(picture_a=self.temp / "gone.png", picture_b=self.temp / "gone_too.png", temp_root=self.temp)
self.assertEqual(caught.exception.status, 404)
if __name__ == "__main__":
unittest.main()