Skip to content

Commit 070d239

Browse files
committed
fix: preserve aspect ratio and center-crop in combine() background scaling
1 parent 60c3aa4 commit 070d239

2 files changed

Lines changed: 94 additions & 2 deletions

File tree

amzqr/amzqr.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,22 @@ def combine(ver, qr_name, bg_name, colorized, contrast, brightness, save_dir, sa
9595

9696
data_w = qr.size[0] - DATA_OFFSET_PX
9797
data_h = qr.size[1] - DATA_OFFSET_PX
98+
99+
# Scale bg to cover the data area, preserving aspect ratio, then
100+
# center-crop to exactly data_w × data_h.
98101
if bg0.size[0] < bg0.size[1]:
99-
bg0 = bg0.resize((data_w, data_w * int(bg0.size[1] / bg0.size[0])))
102+
# Portrait: fit width to data_w, scale height proportionally
103+
new_w = data_w
104+
new_h = int(bg0.size[1] * (new_w / bg0.size[0]))
100105
else:
101-
bg0 = bg0.resize((data_h * int(bg0.size[0] / bg0.size[1]), data_h))
106+
# Landscape or square: fit height to data_h, scale width proportionally
107+
new_h = data_h
108+
new_w = int(bg0.size[0] * (new_h / bg0.size[1]))
109+
110+
bg0 = bg0.resize((new_w, new_h))
111+
left = (new_w - data_w) // 2
112+
top = (new_h - data_h) // 2
113+
bg0 = bg0.crop((left, top, left + data_w, top + data_h))
102114

103115
bg = bg0 if colorized else bg0.convert("1")
104116

tests/test_combine_layout.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,83 @@ def test_combine_respects_reserved_modules(tmp_path):
117117
assert (mx, my) not in reserved, (mx, my)
118118
red = _count_red_in_module(out, mx, my, modules_per_unit, qz)
119119
assert red >= 50, f"data module ({mx},{my}) not painted red: {red}/81"
120+
121+
122+
def test_combine_scaling_preserves_aspect_ratio(tmp_path):
123+
"""Non-square backgrounds should retain their aspect ratio after scaling.
124+
125+
Uses a two-tone background (red top half, blue bottom half) so the
126+
vertical split is visible after center-crop. For a portrait bg, the
127+
top and bottom are cropped equally; the red/blue boundary should
128+
remain near the vertical center of the data area.
129+
"""
130+
words = "https://github.com"
131+
ver, _ = theqrmodule.get_qrcode(1, "H", words, str(tmp_path))
132+
n = (ver - 1) * 4 + 21
133+
134+
# Create a portrait bg (width < height) with distinct top/bottom colors.
135+
# Red top half, blue bottom half — boundary at h/2.
136+
bg_w, bg_h = 80, 160
137+
bg = Image.new("RGBA", (bg_w, bg_h))
138+
for y in range(bg_h):
139+
color = (255, 0, 0, 255) if y < bg_h // 2 else (0, 0, 255, 255)
140+
for x in range(bg_w):
141+
bg.putpixel((x, y), color)
142+
bg_path = tmp_path / "two_tone_portrait.png"
143+
bg.save(bg_path)
144+
145+
out_name = "out.png"
146+
rver, rlevel, out_path = amzqr.run(
147+
words,
148+
level="H",
149+
picture=str(bg_path),
150+
colorized=True,
151+
save_name=out_name,
152+
save_dir=str(tmp_path),
153+
)
154+
assert rver == ver
155+
156+
out = Image.open(out_path).convert("RGBA")
157+
qz = constant.QUIET_ZONE_MODULES
158+
total_modules = n + 2 * qz
159+
assert out.width % total_modules == 0, (out.width, total_modules)
160+
modules_per_unit = out.width // total_modules
161+
assert modules_per_unit == constant.PIXELS_PER_MODULE * 3
162+
163+
# Verify sample modules are not in the reserved set before asserting
164+
# colors, so a version change doesn't produce a misleading failure.
165+
reserved = _reserved_modules(ver, n)
166+
167+
# Sample a data module near the TOP of the QR (module (10, 10)) —
168+
# should be RED because the portrait bg's top half is red.
169+
# Use the top-left sub-pixel (i%3=0, j%3=0) to avoid the combine()
170+
# center-sub-pixel skip (i%3==1 and j%3==1).
171+
top_mx, top_my = 10, 10
172+
assert (top_mx, top_my) not in reserved, f"top sample module ({top_mx},{top_my}) is reserved"
173+
top_x = (qz + top_mx) * modules_per_unit
174+
top_y = (qz + top_my) * modules_per_unit
175+
top_px = out.getpixel((top_x, top_y))
176+
assert _is_red(top_px), (
177+
f"top module ({top_mx},{top_my}) should be red (center-cropped portrait bg), got {top_px}"
178+
)
179+
180+
# Sample a data module near the BOTTOM of the QR (module (10, n-10)) —
181+
# should be BLUE because the portrait bg's bottom half is blue.
182+
bottom_mx, bottom_my = 10, n - 10
183+
assert (bottom_mx, bottom_my) not in reserved, f"bottom sample module ({bottom_mx},{bottom_my}) is reserved"
184+
bottom_x = (qz + bottom_mx) * modules_per_unit
185+
bottom_y = (qz + bottom_my) * modules_per_unit
186+
bottom_px = out.getpixel((bottom_x, bottom_y))
187+
assert bottom_px[2] > 200 and bottom_px[0] < 50 and bottom_px[1] < 50, (
188+
f"bottom module ({bottom_mx},{bottom_my}) should be blue (center-cropped portrait bg), got {bottom_px}"
189+
)
190+
191+
# Also verify reserved modules are still skipped (not colored over).
192+
# Top-left finder area should be mostly untouched (not red/blue).
193+
finder_x0 = qz * modules_per_unit + modules_per_unit // 2
194+
finder_y0 = qz * modules_per_unit + modules_per_unit // 2
195+
fp = out.getpixel((finder_x0, finder_y0))
196+
# Finder center should be dark (black module), not red or blue.
197+
assert fp[0] < 50 and fp[1] < 50 and fp[2] < 50, (
198+
f"finder should be dark, got {fp}"
199+
)

0 commit comments

Comments
 (0)