The pack owned a private stdlib handler, so every line bypassed the coloured console of ComfyUI, and the call sites carried a literal KEEP prefix. The logger now adopts the host console (no handler and propagate=True when ComfyUI is present, one private handler when it is not), the tag lives in a single filter, and the console writes of the pack (the tqdm bars of the processor, the prints of the vendored facelib) reach it through a thread-scoped redirect.
396 lines
13 KiB
Python
396 lines
13 KiB
Python
"""P3.2 - the face processor of ``src/keep_processor.py``.
|
|
|
|
The tests drive the processor with **stubs** (a stub face helper and a stub
|
|
KEEP net) and assert the things the processor is responsible for:
|
|
|
|
* the chunk math of the sequence path (``max_clip_length``),
|
|
* the paste-back (the output frame count and shape),
|
|
* the ``tiled_scale`` wrapper of the optional ComfyUI upscalers,
|
|
* the **console routing**: the four steps are logged and the console writes
|
|
of the vendored facelib reach the logger of the pack.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import importlib
|
|
import logging
|
|
import types
|
|
|
|
import numpy as np
|
|
import pytest
|
|
import torch
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def processor_module():
|
|
return importlib.import_module("ComfyUI_KEEP.src.keep_processor")
|
|
|
|
|
|
class _StubKeepNet:
|
|
"""A KEEP net that records the clips it is called with."""
|
|
|
|
def __init__(self) -> None:
|
|
self.calls: list[tuple] = []
|
|
|
|
def __call__(self, frames, need_upscale=False):
|
|
self.calls.append((tuple(frames.shape), need_upscale))
|
|
return torch.zeros_like(frames)
|
|
|
|
|
|
class _StubFaceHelper:
|
|
"""The facelib helper, reduced to the calls the processor makes."""
|
|
|
|
def __init__(self) -> None:
|
|
self.device = torch.device("cpu")
|
|
self.upscale_factor = 1.0
|
|
self.is_gray = False
|
|
self.cropped_faces: list = []
|
|
self.restored_faces: list = []
|
|
self.all_landmarks_5: list = []
|
|
self.calls: list[str] = []
|
|
|
|
def clean_all(self):
|
|
self.calls.append("clean_all")
|
|
self.cropped_faces = []
|
|
self.restored_faces = []
|
|
|
|
def read_image(self, image):
|
|
self.calls.append("read_image")
|
|
|
|
def align_warp_face(self):
|
|
self.calls.append("align_warp_face")
|
|
|
|
def add_restored_face(self, face, **kwargs):
|
|
self.calls.append("add_restored_face")
|
|
self.restored_faces = [face]
|
|
|
|
def get_inverse_affine(self, *args, **kwargs):
|
|
self.calls.append("get_inverse_affine")
|
|
|
|
def paste_faces_to_input_image(self, *args, **kwargs):
|
|
self.calls.append("paste_faces_to_input_image")
|
|
return np.zeros((512, 512, 3), dtype=np.uint8)
|
|
|
|
|
|
def _make_pack(keep_net, face_helper):
|
|
"""A model pack the processor can consume."""
|
|
return types.SimpleNamespace(
|
|
keep_net=keep_net,
|
|
face_helper=face_helper,
|
|
bg_upscale_model=None,
|
|
face_upscale_model=None,
|
|
device=torch.device("cpu"),
|
|
model_type_str="KEEP",
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_the_sequence_is_split_into_clips_of_max_clip_length(processor_module):
|
|
"""The chunk math of the sequence path (``max_clip_length``)."""
|
|
keep_net = _StubKeepNet()
|
|
face_helper = _StubFaceHelper()
|
|
processor = processor_module.KEEPFaceProcessor(_make_pack(keep_net, face_helper))
|
|
|
|
frames = torch.rand((4, 16, 16, 3), dtype=torch.float32)
|
|
output = processor.process_image_sequence(
|
|
image_sequence_tensor=frames,
|
|
final_upscale_factor=1.0,
|
|
has_aligned_frames=True,
|
|
only_center_face=True,
|
|
draw_box=False,
|
|
max_clip_length=2,
|
|
)
|
|
|
|
# 4 frames, clips of 2 -> two calls of two frames each
|
|
assert len(keep_net.calls) == 2, keep_net.calls
|
|
for shape, need_upscale in keep_net.calls:
|
|
assert shape == (1, 2, 3, 512, 512), shape
|
|
assert need_upscale is False
|
|
# the paste-back produced one frame per input frame
|
|
assert tuple(output.shape) == (4, 512, 512, 3), output.shape
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_an_empty_sequence_is_returned_unchanged(processor_module):
|
|
"""No frames -> no work, the input is returned."""
|
|
keep_net = _StubKeepNet()
|
|
processor = processor_module.KEEPFaceProcessor(
|
|
_make_pack(keep_net, _StubFaceHelper())
|
|
)
|
|
frames = torch.zeros((0, 16, 16, 3), dtype=torch.float32)
|
|
output = processor.process_image_sequence(
|
|
image_sequence_tensor=frames,
|
|
final_upscale_factor=1.0,
|
|
has_aligned_frames=True,
|
|
only_center_face=True,
|
|
draw_box=False,
|
|
max_clip_length=20,
|
|
)
|
|
assert output is frames
|
|
assert keep_net.calls == []
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_the_upscaler_goes_through_tiled_scale(processor_module):
|
|
"""The optional ComfyUI upscaler is called through ``tiled_scale``."""
|
|
calls: list[dict] = []
|
|
|
|
class _StubUpscaleModel:
|
|
scale = 2.0
|
|
|
|
def __init__(self):
|
|
self.model = self._model
|
|
|
|
def _model(self, samples):
|
|
calls.append({"shape": tuple(samples.shape)})
|
|
return samples * 2.0
|
|
|
|
processor = processor_module.KEEPFaceProcessor(
|
|
_make_pack(_StubKeepNet(), _StubFaceHelper())
|
|
)
|
|
image = np.zeros((8, 8, 3), dtype=np.uint8)
|
|
result = processor._run_upscaler(_StubUpscaleModel(), image)
|
|
|
|
assert len(calls) == 1, calls
|
|
assert calls[0]["shape"] == (1, 3, 8, 8), calls
|
|
assert result.shape == (8, 8, 3), result.shape
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_without_an_upscaler_the_image_is_untouched(processor_module):
|
|
"""No upscaler -> the image is returned unchanged."""
|
|
processor = processor_module.KEEPFaceProcessor(
|
|
_make_pack(_StubKeepNet(), _StubFaceHelper())
|
|
)
|
|
image = np.full((4, 4, 3), 7, dtype=np.uint8)
|
|
assert processor._run_upscaler(None, image) is image
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_thirty_frames_are_split_into_clips_of_twenty(processor_module):
|
|
"""The chunk math of the sequence path for 30 frames / ``max_clip_length=20``."""
|
|
keep_net = _StubKeepNet()
|
|
face_helper = _StubFaceHelper()
|
|
processor = processor_module.KEEPFaceProcessor(_make_pack(keep_net, face_helper))
|
|
|
|
frames = torch.rand((30, 16, 16, 3), dtype=torch.float32)
|
|
output = processor.process_image_sequence(
|
|
image_sequence_tensor=frames,
|
|
final_upscale_factor=1.0,
|
|
has_aligned_frames=True,
|
|
only_center_face=True,
|
|
draw_box=False,
|
|
max_clip_length=20,
|
|
)
|
|
|
|
# 30 frames, clips of 20 -> one call of 20 frames and one of 10
|
|
assert len(keep_net.calls) == 2, keep_net.calls
|
|
shapes = [shape for shape, _ in keep_net.calls]
|
|
assert shapes == [(1, 20, 3, 512, 512), (1, 10, 3, 512, 512)], shapes
|
|
assert tuple(output.shape) == (30, 512, 512, 3), output.shape
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# P4.6 - the VRAM is released through ComfyUI
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.unit
|
|
def test_the_vram_is_released_through_comfyui(processor_module):
|
|
"""``_soft_empty_cache`` goes through ``model_management`` (P4.6)."""
|
|
model_management = importlib.import_module("comfy.model_management")
|
|
model_management.calls.clear()
|
|
|
|
assert processor_module._soft_empty_cache() is True
|
|
assert ("soft_empty_cache",) in model_management.calls, model_management.calls
|
|
|
|
|
|
@pytest.mark.mock_integration
|
|
def test_the_sequence_releases_the_vram_at_every_chunk_boundary(
|
|
processor_module, monkeypatch
|
|
):
|
|
"""P4.6: every chunk boundary releases the cached VRAM.
|
|
|
|
30 frames with ``max_clip_length=20`` are two chunks - the release must
|
|
happen after each of them (the next chunk is allocated right after).
|
|
"""
|
|
events: list[str] = []
|
|
|
|
class _RecordingKeepNet(_StubKeepNet):
|
|
def __call__(self, frames, need_upscale=False):
|
|
events.append("net")
|
|
return super().__call__(frames, need_upscale=need_upscale)
|
|
|
|
monkeypatch.setattr(
|
|
processor_module, "_soft_empty_cache", lambda: events.append("empty_cache")
|
|
)
|
|
|
|
processor = processor_module.KEEPFaceProcessor(
|
|
_make_pack(_RecordingKeepNet(), _StubFaceHelper())
|
|
)
|
|
frames = torch.rand((30, 16, 16, 3), dtype=torch.float32)
|
|
processor.process_image_sequence(
|
|
image_sequence_tensor=frames,
|
|
final_upscale_factor=1.0,
|
|
has_aligned_frames=True,
|
|
only_center_face=True,
|
|
draw_box=False,
|
|
max_clip_length=20,
|
|
)
|
|
|
|
net_positions = [index for index, event in enumerate(events) if event == "net"]
|
|
assert len(net_positions) == 2, events
|
|
for position in net_positions:
|
|
assert events[position + 1] == "empty_cache", (
|
|
f"the chunk boundary did not release the VRAM: {events}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# D4 - the console routing of the processor
|
|
# ---------------------------------------------------------------------------
|
|
class _DetectingFaceHelper(_StubFaceHelper):
|
|
"""A face helper that **detects** a face - and prints like the facelib.
|
|
|
|
The vendored helper writes to the console (``print()``) while it reads
|
|
the image, while it detects, while it aligns a face and while it pastes
|
|
it back: the processor must route those writes to the logger of the pack.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.landmarks = np.zeros(10, dtype=np.float32)
|
|
|
|
def get_face_landmarks_5(self, **kwargs):
|
|
self.calls.append("get_face_landmarks_5")
|
|
print("model_rootpath is None. Using default relative path")
|
|
self.all_landmarks_5 = [self.landmarks]
|
|
return 1
|
|
|
|
def align_warp_face(self):
|
|
super().align_warp_face()
|
|
print("Warning: Could not estimate affine matrix for landmark set 0.")
|
|
|
|
def paste_faces_to_input_image(self, *args, **kwargs):
|
|
super().paste_faces_to_input_image(*args, **kwargs)
|
|
print("Warning: Mismatch between restored_faces and inverse_affine_matrices.")
|
|
return np.zeros((512, 512, 3), dtype=np.uint8)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _pack_records():
|
|
"""The records the logger of the pack receives while the block runs."""
|
|
pack_logger = importlib.import_module("ComfyUI_KEEP.src.keep_logger").logger
|
|
records: list[logging.LogRecord] = []
|
|
|
|
class _Capture(logging.Handler):
|
|
def emit(self, record):
|
|
records.append(record)
|
|
|
|
handler = _Capture()
|
|
pack_logger.addHandler(handler)
|
|
try:
|
|
yield records
|
|
finally:
|
|
pack_logger.removeHandler(handler)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_the_four_steps_of_the_sequence_are_logged(processor_module):
|
|
"""The sequence path logs its four steps - tagged, once each."""
|
|
processor = processor_module.KEEPFaceProcessor(
|
|
_make_pack(_StubKeepNet(), _DetectingFaceHelper())
|
|
)
|
|
frames = torch.rand((2, 16, 16, 3), dtype=torch.float32)
|
|
|
|
with _pack_records() as records:
|
|
processor.process_image_sequence(
|
|
image_sequence_tensor=frames,
|
|
final_upscale_factor=1.0,
|
|
has_aligned_frames=False,
|
|
only_center_face=True,
|
|
draw_box=False,
|
|
)
|
|
|
|
steps = [
|
|
"[KEEP] Detecting face landmarks",
|
|
"[KEEP] Cropping and aligning faces",
|
|
"[KEEP] Restoring faces with KEEP",
|
|
"[KEEP] Pasting faces and finalizing frames",
|
|
]
|
|
messages = [record.getMessage() for record in records]
|
|
# the records of the console routing are in between - the four steps are
|
|
# logged once each and in this order
|
|
assert [message for message in messages if message in steps] == steps
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_the_console_writes_of_the_detection_reach_the_logger(processor_module):
|
|
"""A print of the vendored detector becomes a tagged record."""
|
|
processor = processor_module.KEEPFaceProcessor(
|
|
_make_pack(_StubKeepNet(), _DetectingFaceHelper())
|
|
)
|
|
frames = torch.rand((1, 16, 16, 3), dtype=torch.float32)
|
|
|
|
with _pack_records() as records:
|
|
processor.process_image_sequence(
|
|
image_sequence_tensor=frames,
|
|
final_upscale_factor=1.0,
|
|
has_aligned_frames=False,
|
|
only_center_face=True,
|
|
draw_box=False,
|
|
)
|
|
|
|
messages = [record.getMessage() for record in records]
|
|
assert (
|
|
"[KEEP] model_rootpath is None. Using default relative path" in messages
|
|
), messages
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_every_console_write_of_the_facelib_reaches_the_logger(processor_module):
|
|
"""The prints of the detection, of the alignment and of the paste-back."""
|
|
processor = processor_module.KEEPFaceProcessor(
|
|
_make_pack(_StubKeepNet(), _DetectingFaceHelper())
|
|
)
|
|
frames = torch.rand((1, 16, 16, 3), dtype=torch.float32)
|
|
|
|
with _pack_records() as records:
|
|
processor.process_image_sequence(
|
|
image_sequence_tensor=frames,
|
|
final_upscale_factor=1.0,
|
|
has_aligned_frames=False,
|
|
only_center_face=True,
|
|
draw_box=False,
|
|
)
|
|
|
|
messages = [record.getMessage() for record in records]
|
|
for text in (
|
|
"model_rootpath is None. Using default relative path",
|
|
"Warning: Could not estimate affine matrix for landmark set 0.",
|
|
"Warning: Mismatch between restored_faces and inverse_affine_matrices.",
|
|
):
|
|
assert f"[KEEP] {text}" in messages, (text, messages)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_the_console_writes_of_the_image_path_reach_the_logger(processor_module):
|
|
"""The single image path routes the detection prints as well."""
|
|
processor = processor_module.KEEPFaceProcessor(
|
|
_make_pack(_StubKeepNet(), _DetectingFaceHelper())
|
|
)
|
|
image = np.zeros((16, 16, 3), dtype=np.uint8)
|
|
|
|
with _pack_records() as records:
|
|
processor.process_image(
|
|
cv2_image_orig=image,
|
|
final_upscale_factor=1.0,
|
|
has_aligned=False,
|
|
only_center_face=True,
|
|
draw_box=False,
|
|
)
|
|
|
|
messages = [record.getMessage() for record in records]
|
|
assert (
|
|
"[KEEP] model_rootpath is None. Using default relative path" in messages
|
|
), messages
|