Skip to content

Commit 62ac01c

Browse files
committed
fix: resolve inpainting mask variable-shadowing bug causing AttributeError
Rename inpainting mask accumulator from 'masks' to 'inpainting_masks' in train_util.py __getitem__ to prevent the SDXL attention-mask assignment from overwriting it. Also strengthen the inpainting guard in train_network.py to check both masks and masked_images before entering the inpainting branch.
1 parent 81e6ad7 commit 62ac01c

3 files changed

Lines changed: 323 additions & 4 deletions

File tree

library/train_util.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1719,7 +1719,7 @@ def __getitem__(self, index):
17191719
flippeds = [] # 変数名が微妙
17201720
text_encoder_outputs_list = []
17211721
custom_attributes = []
1722-
masks = []
1722+
inpainting_masks = []
17231723
masked_images = []
17241724

17251725
for image_key in bucket[image_index : image_index + bucket_batch_size]:
@@ -1824,7 +1824,7 @@ def __getitem__(self, index):
18241824
mask = self.random_mask(pil_image.size)
18251825
mask, masked_image = self.prepare_mask_and_masked_image(pil_image, mask)
18261826

1827-
masks.append(mask)
1827+
inpainting_masks.append(mask)
18281828
masked_images.append(masked_image)
18291829

18301830
latents = None
@@ -1976,7 +1976,7 @@ def none_or_stack_elements(tensors_list, converter):
19761976
images = None
19771977
example["images"] = images
19781978

1979-
example["masks"] = torch.stack(masks) if masks else None
1979+
example["masks"] = torch.stack(inpainting_masks) if inpainting_masks else None
19801980
example["masked_images"] = torch.stack(masked_images) if masked_images else None
19811981

19821982
example["latents"] = torch.stack(latents_list) if latents_list[0] is not None else None
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
"""
2+
Regression tests for the inpainting mask / attention mask variable-shadowing bug.
3+
4+
Bug summary
5+
-----------
6+
In ``library/train_util.py``, the variable ``masks`` was used for both the
7+
inpainting mask accumulator (line 1722) and the SDXL per-image attention mask
8+
(line 1859). The attention-mask assignment overwrote the inpainting list,
9+
causing ``example["masks"]`` to contain attention-mask data even when inpainting
10+
was not configured. Downstream in ``train_network.py``, the guard only checked
11+
``batch.get("masks")`` — which was non-None — and then tried to access
12+
``batch["masked_images"]``, which was ``None``, triggering an ``AttributeError``.
13+
14+
Fixes applied
15+
~~~~~~~~~~~~~
16+
1. ``train_util.py``: The inpainting mask accumulator is now named
17+
``inpainting_masks`` to avoid shadowing the attention-mask variable.
18+
2. ``train_network.py:626``: The guard now checks *both* ``masks`` and
19+
``masked_images``.
20+
21+
This test module verifies both fixes using source-level assertions and
22+
targeted functional tests with mock objects.
23+
"""
24+
25+
import ast
26+
import os
27+
import sys
28+
import textwrap
29+
30+
import pytest
31+
32+
# Allow imports from the sd_scripts package root
33+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
34+
35+
36+
# ---------------------------------------------------------------------------
37+
# Helpers
38+
# ---------------------------------------------------------------------------
39+
40+
def _read_source(relpath: str) -> str:
41+
"""Read a source file relative to the sd_scripts directory."""
42+
base = os.path.join(os.path.dirname(__file__), "..")
43+
with open(os.path.join(base, relpath), encoding="utf-8") as f:
44+
return f.read()
45+
46+
47+
def _get_assign_targets(source: str, line_number: int) -> list[str]:
48+
"""Return the left-hand-side names of an assignment on *line_number*."""
49+
tree = ast.parse(source)
50+
for node in ast.walk(tree):
51+
if isinstance(node, ast.Assign) and node.lineno == line_number:
52+
return [t.id if isinstance(t, ast.Name) else t.attr for t in node.targets if isinstance(t, (ast.Name, ast.Attribute))]
53+
if isinstance(node, ast.AugAssign) and node.lineno == line_number:
54+
t = node.target
55+
return [t.id if isinstance(t, ast.Name) else t.attr]
56+
return []
57+
58+
59+
# ---------------------------------------------------------------------------
60+
# Tests for train_util.py fix: variable shadowing elimination
61+
# ---------------------------------------------------------------------------
62+
63+
class TestTrainUtilVariableShadowing:
64+
"""Verify that ``inpainting_masks`` is used instead of ``masks`` for
65+
the inpainting mask accumulator in ``__getitem__``."""
66+
67+
def test_inpainting_accumulator_is_renamed(self):
68+
"""The inpainting mask list should be named ``inpainting_masks``,
69+
not ``masks``, so it cannot be overwritten by the attention-mask
70+
assignment later in the method."""
71+
source = _read_source("library/train_util.py")
72+
# Find the __getitem__ method body
73+
lines = source.splitlines()
74+
75+
# Look for the initialization of the inpainting mask accumulator.
76+
# It should be ``inpainting_masks = []`` not ``masks = []``
77+
found_inpainting_init = False
78+
found_masks_init_in_same_position = False
79+
for i, line in enumerate(lines, start=1):
80+
stripped = line.strip()
81+
if stripped == "inpainting_masks = []":
82+
found_inpainting_init = True
83+
# The old buggy pattern: masks = [] in the accumulator position
84+
# This should NOT exist in the __getitem__ method after the fix
85+
if i < 1800 and stripped == "masks = []":
86+
found_masks_init_in_same_position = True
87+
88+
assert found_inpainting_init, (
89+
"Expected 'inpainting_masks = []' initialization in __getitem__"
90+
)
91+
assert not found_masks_init_in_same_position, (
92+
"The old 'masks = []' accumulator should be renamed to 'inpainting_masks = []'"
93+
)
94+
95+
def test_inpainting_append_uses_renamed_variable(self):
96+
"""The append inside the ``if self.train_inpainting`` block should
97+
use ``inpainting_masks.append(mask)``."""
98+
source = _read_source("library/train_util.py")
99+
assert "inpainting_masks.append(mask)" in source, (
100+
"Expected 'inpainting_masks.append(mask)' in the inpainting block"
101+
)
102+
103+
def test_example_masks_uses_renamed_variable(self):
104+
"""The ``example["masks"]`` assignment should reference
105+
``inpainting_masks``, not ``masks``."""
106+
source = _read_source("library/train_util.py")
107+
# The line should be:
108+
# example["masks"] = torch.stack(inpainting_masks) if inpainting_masks else None
109+
lines = source.splitlines()
110+
found = False
111+
for line in lines:
112+
stripped = line.strip()
113+
if 'example["masks"]' in stripped and "inpainting_masks" in stripped:
114+
found = True
115+
break
116+
assert found, (
117+
'Expected example["masks"] to reference inpainting_masks, not masks'
118+
)
119+
120+
def test_attention_mask_variable_not_shadowed(self):
121+
"""After the fix, the SDXL attention-mask variable ``masks`` (set at
122+
line ~1859 and ~1902) should be a *separate* variable from the
123+
inpainting accumulator. We verify that ``masks`` is still used for
124+
attention masks in the tokenization block."""
125+
source = _read_source("library/train_util.py")
126+
# The attention-mask assignment should still exist
127+
assert "masks = None" in source, (
128+
"The attention-mask variable 'masks = None' should still be present"
129+
)
130+
assert "masks = mask_iter" in source, (
131+
"The SDXL attention-mask assignment 'masks = mask_iter' should still be present"
132+
)
133+
# The attention mask should be appended to attn_mask_list
134+
assert "attn_mask_list.append(masks)" in source, (
135+
"Attention mask should be appended to attn_mask_list"
136+
)
137+
138+
139+
# ---------------------------------------------------------------------------
140+
# Tests for train_network.py fix: dual guard
141+
# ---------------------------------------------------------------------------
142+
143+
class TestTrainNetworkGuard:
144+
"""Verify that the inpainting guard in ``train_network.py`` checks both
145+
``masks`` and ``masked_images`` before attempting to encode."""
146+
147+
def test_guard_checks_both_keys(self):
148+
"""The guard should check ``batch.get("masked_images")`` in addition
149+
to ``batch.get("masks")``."""
150+
source = _read_source("train_network.py")
151+
lines = source.splitlines()
152+
153+
guard_line = None
154+
for i, line in enumerate(lines, start=1):
155+
if 'batch.get("masks") is not None' in line and "masked_images" in line:
156+
guard_line = (i, line.strip())
157+
break
158+
159+
assert guard_line is not None, (
160+
"Expected a guard line checking both 'masks' and 'masked_images' in train_network.py"
161+
)
162+
line_num, content = guard_line
163+
assert 'batch.get("masked_images") is not None' in content, (
164+
"Guard should also check batch.get('masked_images') is not None"
165+
)
166+
167+
168+
# ---------------------------------------------------------------------------
169+
# Functional tests: simulate the crash scenario
170+
# ---------------------------------------------------------------------------
171+
172+
class TestInpaintingMaskCrashScenario:
173+
"""Functional tests that simulate the data flow through ``__getitem__``
174+
and the ``train_network.py`` guard to verify the crash no longer occurs."""
175+
176+
def test_non_inpainting_batch_masks_none(self):
177+
"""When inpainting is not configured, ``example["masks"]`` should be
178+
``None`` (not populated with attention masks). This simulates the
179+
fixed data flow."""
180+
import torch
181+
182+
# Simulate the fixed __getitem__ data flow:
183+
# inpainting_masks = [] (accumulator, never appended to)
184+
inpainting_masks = []
185+
masked_images = []
186+
187+
# SDXL attention masks (separate variable now)
188+
# These would be populated by tokenize_strategy.tokenize()
189+
attention_masks = None # or could be a tensor for SDXL
190+
191+
# The example dict construction (fixed version)
192+
example = {}
193+
example["masks"] = torch.stack(inpainting_masks) if inpainting_masks else None
194+
example["masked_images"] = torch.stack(masked_images) if masked_images else None
195+
196+
assert example["masks"] is None, "masks should be None when not doing inpainting"
197+
assert example["masked_images"] is None, "masked_images should be None when not doing inpainting"
198+
199+
def test_non_inpainting_sdxl_attention_mask_scenario(self):
200+
"""When using SDXL tokenization (attention masks are non-None) but
201+
inpainting is disabled, ``example["masks"]`` should still be ``None``."""
202+
import torch
203+
204+
# Simulate the fixed data flow:
205+
inpainting_masks = []
206+
masked_images = []
207+
208+
# SDXL attention mask tokenization sets a separate 'masks' variable
209+
# but now it doesn't affect inpainting_masks
210+
sdxl_attention_mask = torch.ones(2, 77) # example SDXL attention mask
211+
212+
# In the fixed code, the attention mask goes to attn_mask_list,
213+
# not to the inpainting masks variable
214+
example = {}
215+
example["masks"] = torch.stack(inpainting_masks) if inpainting_masks else None
216+
example["masked_images"] = torch.stack(masked_images) if masked_images else None
217+
218+
assert example["masks"] is None, (
219+
"masks should be None even when SDXL attention masks are present"
220+
)
221+
assert example["masked_images"] is None
222+
223+
def test_inpainting_batch_both_populated(self):
224+
"""When inpainting IS configured, both ``example["masks"]`` and
225+
``example["masked_images"]`` should be populated."""
226+
import torch
227+
228+
# Simulate the fixed data flow with inpainting enabled:
229+
inpainting_masks = [torch.ones(1, 64, 64), torch.ones(1, 64, 64)]
230+
masked_images = [torch.randn(3, 512, 512), torch.randn(3, 512, 512)]
231+
232+
example = {}
233+
example["masks"] = torch.stack(inpainting_masks) if inpainting_masks else None
234+
example["masked_images"] = torch.stack(masked_images) if masked_images else None
235+
236+
assert example["masks"] is not None
237+
assert example["masked_images"] is not None
238+
assert example["masks"].shape == (2, 1, 64, 64)
239+
assert example["masked_images"].shape == (2, 3, 512, 512)
240+
241+
def test_guard_blocks_when_only_masks_populated(self):
242+
"""The guard should NOT enter the inpainting branch when ``masks``
243+
contains data but ``masked_images`` is ``None``. This simulates
244+
the old bug scenario."""
245+
import torch
246+
247+
# Old buggy scenario: masks is attention mask data (non-None),
248+
# but masked_images is None
249+
batch = {
250+
"masks": torch.ones(2, 77), # attention mask, not inpainting mask
251+
"masked_images": None,
252+
}
253+
254+
# Fixed guard: check BOTH conditions
255+
guard_passes = batch.get("masks") is not None and batch.get("masked_images") is not None
256+
257+
assert not guard_passes, (
258+
"Guard should NOT pass when only masks is populated (attention mask scenario)"
259+
)
260+
261+
def test_guard_passes_when_both_populated(self):
262+
"""The guard should enter the inpainting branch when both ``masks``
263+
and ``masked_images`` are populated."""
264+
import torch
265+
266+
batch = {
267+
"masks": torch.ones(2, 1, 64, 64),
268+
"masked_images": torch.randn(2, 3, 512, 512),
269+
}
270+
271+
guard_passes = batch.get("masks") is not None and batch.get("masked_images") is not None
272+
273+
assert guard_passes, (
274+
"Guard should pass when both masks and masked_images are populated"
275+
)
276+
277+
def test_guard_blocks_when_masks_none(self):
278+
"""The guard should NOT pass when ``masks`` is ``None``."""
279+
batch = {
280+
"masks": None,
281+
"masked_images": None,
282+
}
283+
284+
guard_passes = batch.get("masks") is not None and batch.get("masked_images") is not None
285+
286+
assert not guard_passes
287+
288+
def test_guard_blocks_when_masks_missing(self):
289+
"""The guard should NOT pass when ``masks`` key is missing entirely."""
290+
batch = {
291+
"masked_images": None,
292+
}
293+
294+
guard_passes = batch.get("masks") is not None and batch.get("masked_images") is not None
295+
296+
assert not guard_passes
297+
298+
def test_old_guard_would_crash(self):
299+
"""Demonstrate that the OLD guard (only checking masks) would have
300+
entered the branch and crashed on masked_images being None."""
301+
import torch
302+
303+
# This is the exact scenario that caused the crash
304+
batch = {
305+
"masks": torch.ones(2, 77), # non-None attention mask
306+
"masked_images": None, # None because inpainting not configured
307+
}
308+
309+
# Old guard (buggy):
310+
old_guard = batch.get("masks") is not None
311+
assert old_guard, "Old guard would have entered the branch"
312+
313+
# The crash would occur on: batch["masked_images"].to(...)
314+
with pytest.raises(AttributeError):
315+
batch["masked_images"].to("cpu")
316+
317+
# New guard (fixed):
318+
new_guard = batch.get("masks") is not None and batch.get("masked_images") is not None
319+
assert not new_guard, "New guard correctly prevents entering the branch"

train_network.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,7 @@ def process_batch(
623623
latents = self.shift_scale_latents(args, latents)
624624

625625
# Prepare inpainting masked_latents if batch contains masks
626-
if batch.get("masks") is not None:
626+
if batch.get("masks") is not None and batch.get("masked_images") is not None:
627627
masked_latents = self.encode_images_to_latents(
628628
args, vae, batch["masked_images"].to(accelerator.device, dtype=vae_dtype)
629629
)

0 commit comments

Comments
 (0)