feat(character): ComfyUI blueprints and a render-vs-blueprint evaluation

Two tools that turn character work into something measurable.

- Tools/comfyui_character_blueprint.py generates a four-view sheet (front, side, back,
  head) plus a contact sheet from one description, with fixed seeds and a JSON record
  of prompts and prompt ids. First sheet: 'GigLine walker', lean pedestrian in a navy
  tee, jeans and sneakers.
- Tools/character_match.py compares a rendered character against a sheet: figure
  aspect, fill and coverage deltas, mean CIE76 colour distance, dominant-palette
  distance, and a side-by-side montage. The deltas stay separate on purpose - a single
  similarity score between a photoreal sheet and a low-detail body would be a number
  with no meaning.
- comfyui_generate.py grew wait_for_image() so both tools share the polling path
  instead of duplicating it.
- 13 unit tests for the colour maths, masks and palette; 95 tool tests pass.

First real result, recorded rather than smoothed over: aspect delta 1.24, fill delta
0.12, mean deltaE 31.9, palette deltaE 25.4. The montage shows the reason - the model
is a nude body and the blueprint wears a tee and jeans, so clothing dominates every
metric. Figure segmentation is explicit (--render-box): auto-detection picked backdrop
blobs on every attempt, and a background plate is the proper fix.
This commit is contained in:
zwitschi committed 2026-11-09 13:36:28 +01:00
1 parent 1fc5835327
commit 85a8437cbc
56 files changed
+841 -170

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
"""Tests for the pure parts of Tools/character_match.py."""
import os
import sys
import unittest
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import character_match as cm # noqa: E402
class ColourTests(unittest.TestCase):
def test_white_is_light_and_neutral(self):
light, a, b = cm.rgb_to_lab((255, 255, 255))
self.assertAlmostEqual(light, 100.0, delta=0.2)
self.assertAlmostEqual(a, 0.0, delta=0.2)
self.assertAlmostEqual(b, 0.0, delta=0.2)
def test_black_is_dark(self):
self.assertAlmostEqual(cm.rgb_to_lab((0, 0, 0))[0], 0.0, delta=0.1)
def test_identical_colours_have_no_distance(self):
self.assertAlmostEqual(cm.delta_e76(cm.rgb_to_lab((120, 90, 70)),
cm.rgb_to_lab((120, 90, 70))), 0.0, delta=1e-9)
def test_red_green_are_far_apart(self):
distance = cm.delta_e76(cm.rgb_to_lab((200, 40, 40)), cm.rgb_to_lab((40, 160, 40)))
self.assertGreater(distance, 40.0)
class MaskTests(unittest.TestCase):
def _image(self, figure_colour, background_colour):
image = Image.new("RGB", (60, 100), background_colour)
pixels = image.load()
for y in range(20, 90):
for x in range(25, 35):
pixels[x, y] = figure_colour
return image
def test_saturated_figure_on_grey_is_found(self):
mask = cm._subject_mask(self._image((150, 90, 60), (190, 190, 190)))
box = cm.bbox(mask)
self.assertIsNotNone(box)
self.assertLess(box[0], 30)
self.assertGreater(box[2], 30)
def test_grey_frame_yields_empty_mask(self):
mask = cm._subject_mask(self._image((190, 190, 190), (190, 190, 190)))
self.assertIsNone(cm.bbox(mask))
def test_stats_report_figure_proportions(self):
mask = Image.new("L", (40, 80), 0)
pixels = mask.load()
for y in range(10, 70):
for x in range(15, 25):
pixels[x, y] = 255
stats = cm.mask_stats(mask)
self.assertEqual(stats["area"], 600)
self.assertAlmostEqual(stats["aspect"], 6.0, delta=0.01)
self.assertAlmostEqual(stats["fill"], 1.0, delta=0.01)
class PaletteTests(unittest.TestCase):
def test_dominant_colour_is_reported(self):
colours = [(200, 40, 40)] * 90 + [(20, 60, 200)] * 10
palette = cm.dominant(colours, count=2)
self.assertEqual(len(palette), 2)
red, _, _ = cm.rgb_to_lab(palette[0])
self.assertGreater(red, 30.0)
def test_palette_distance_is_zero_for_the_same_palette(self):
palette = cm.dominant([(200, 40, 40)] * 50)
self.assertAlmostEqual(cm.palette_delta_e(palette, palette), 0.0, delta=1e-6)
def test_empty_palette_is_not_a_number(self):
self.assertNotEqual(cm.palette_delta_e([], [(1, 2, 3)]),
cm.palette_delta_e([], [(1, 2, 3)]))
if __name__ == "__main__":
unittest.main()