diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 21:54:01 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 21:54:01 +0800 |
| commit | 87a00a036219f0f1b9c3eaccd7fcd9d782fe6011 (patch) | |
| tree | f39ea3a04739705e84bce6558b1230da55495f98 | |
| parent | b5ffb35fc0824a297ff88297390310acb6c45335 (diff) | |
| download | living-village-87a00a036219f0f1b9c3eaccd7fcd9d782fe6011.tar.gz | |
test(desktop): 预乘/光晕/水面/站位回归与夜间白块校验脚本
- premultiply 定点:核心 (255,226,168,34)->(34,30,22,34),(252,196,128,34)->(34,26,17,34),
通道恒 <= alpha、<250 无白
- lanternGlowLayers 径向/预乘/无白、水面 per-tile 三相、角色脚底对齐断言
- scripts/check-no-white-blocks.py:夜晚帧不得有 >=64x64、每通道>=250、alpha>=250 的块
(P13 旧帧 42/69 命中,P14 修复后 0 命中)
| -rw-r--r-- | scripts/check-no-white-blocks.py | 93 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 80 |
2 files changed, 173 insertions, 0 deletions
diff --git a/scripts/check-no-white-blocks.py b/scripts/check-no-white-blocks.py new file mode 100644 index 0000000..609c832 --- /dev/null +++ b/scripts/check-no-white-blocks.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Fail if any recorded frame contains a near-white opaque block. + +This is the image-level regression guard for the P13/P14 lantern blow-out: a +premultiplied-alpha mistake painted ~75x75 pure white (255,255,255) squares +where the night lantern glow should fall off softly. + +A "white block" is any window of at least MIN_SIDE x MIN_SIDE pixels where every +pixel is fully opaque (alpha >= 250) and every colour channel is >= 250. + +Usage: + python3 scripts/check-no-white-blocks.py <png-or-dir> [more...] + +Exit code 0 when clean, 1 when a block is found, 2 on usage/read error. +""" + +import os +import sys + +import numpy as np +from PIL import Image + +MIN_SIDE = 64 +CHANNEL_MIN = 250 +ALPHA_MIN = 250 + + +def first_white_block(path, min_side=MIN_SIDE): + image = Image.open(path) + rgba = np.asarray(image.convert("RGBA"), dtype=np.uint8) + if rgba.shape[0] < min_side or rgba.shape[1] < min_side: + return None + white_opaque = ( + (rgba[:, :, 0] >= CHANNEL_MIN) + & (rgba[:, :, 1] >= CHANNEL_MIN) + & (rgba[:, :, 2] >= CHANNEL_MIN) + & (rgba[:, :, 3] >= ALPHA_MIN) + ).astype(np.int64) + # Summed-area table so any k x k window is one lookup. + integral = np.zeros((white_opaque.shape[0] + 1, white_opaque.shape[1] + 1), dtype=np.int64) + integral[1:, 1:] = white_opaque.cumsum(0).cumsum(1) + rows, cols = white_opaque.shape + for y in range(0, rows - min_side + 1): + for x in range(0, cols - min_side + 1): + total = ( + integral[y + min_side, x + min_side] + - integral[y, x + min_side] + - integral[y + min_side, x] + + integral[y, x] + ) + if total == min_side * min_side: + return (x, y, min_side, min_side) + return None + + +def iter_pngs(targets): + for target in targets: + if os.path.isdir(target): + for root, _, files in os.walk(target): + for name in sorted(files): + if name.lower().endswith(".png"): + yield os.path.join(root, name) + else: + yield target + + +def main(argv): + if len(argv) < 2: + sys.stderr.write(__doc__) + return 2 + checked = 0 + failures = [] + for path in iter_pngs(argv[1:]): + try: + block = first_white_block(path) + except Exception as error: # noqa: BLE001 - report and continue + sys.stderr.write(f"unreadable: {path}: {error}\n") + return 2 + checked += 1 + if block is not None: + x, y, side, _ = block + failures.append(f"{path}: white block {side}x{side} at ({x},{y})") + for line in failures: + print(f"FAIL {line}") + if failures: + print(f"checked={checked} failures={len(failures)}") + return 1 + print(f"checked={checked} failures=0 OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index 46fb878..d5602e8 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -534,6 +534,86 @@ type PrototypeTests () = Assert.AreEqual<int>(VillageArt.smokeFrameTick 99L, VillageArt.smokeFrameTick 99L) [<TestMethod>] + member _.PremultiplyScalesEachChannelByItsOwnAlpha () = + // The warm lantern core: rgb(255,226,168) at alpha 34 must become 34/30/22 so the + // premultiplied SpriteBatch blend adds a dim warm value instead of full rgb. + let core = VillageArt.premultiply 255 226 168 34 + Assert.AreEqual<byte>(34uy, core.R) + Assert.AreEqual<byte>(30uy, core.G) + Assert.AreEqual<byte>(22uy, core.B) + Assert.AreEqual<byte>(34uy, core.A) + + // General fixed-point behaviour (round half away from zero). + let warm = VillageArt.premultiply 252 196 128 34 + Assert.AreEqual<byte>(34uy, warm.R) + Assert.AreEqual<byte>(26uy, warm.G) + Assert.AreEqual<byte>(17uy, warm.B) + Assert.AreEqual<byte>(34uy, warm.A) + + // Alpha 255 is the identity; alpha 0 zeroes the colour. + Assert.AreEqual<Color>(Color(252, 196, 128, 255), VillageArt.premultiply 252 196 128 255) + Assert.AreEqual<Color>(Color(0, 0, 0, 0), VillageArt.premultiply 252 196 128 0) + + // A premultiplied channel can never exceed its own alpha, which is what makes an + // opaque white core impossible for any alpha < 250. + for alpha in [ 0; 1; 34; 70; 128; 200; 249 ] do + let c = VillageArt.premultiply 255 255 255 alpha + Assert.IsTrue(int c.R <= int c.A && int c.G <= int c.A && int c.B <= int c.A) + Assert.IsTrue(int (max c.R (max c.G c.B)) < 250) + + [<TestMethod>] + member _.WaterRipplePhaseIsDistributedPerTile () = + // Neighbouring tiles must not share a phase in the same frame. + let a = VillageArt.waterFrameTickAt 128L 10 20 + let b = VillageArt.waterFrameTickAt 128L 11 20 + let c = VillageArt.waterFrameTickAt 128L 10 21 + Assert.AreNotEqual<int>(a, b) + Assert.AreNotEqual<int>(a, c) + + // Pure and in range 0..2 across the map and across ticks. + for tick in [ 0L; 31L; 32L; 40L; 64L; 127L; 200L ] do + for x in 0 .. 7 do + for y in 0 .. 7 do + let phase = VillageArt.waterFrameTickAt tick x y + Assert.IsTrue(phase >= 0 && phase <= 2) + Assert.AreEqual<int>(phase, VillageArt.waterFrameTickAt tick x y) + + // A horizontal strip of river tiles shows at least two distinct phases at once. + let strip = [ 0 .. 11 ] |> List.map (fun x -> VillageArt.waterFrameTickAt 128L x 12) + Assert.IsTrue(Set.ofList strip |> Set.count >= 2) + + [<TestMethod>] + member _.LanternGlowLayersArePremultipliedAndRadial () = + let centerX, centerY, radius, rings, peakAlpha = 100, 100, 44, 6, 140 + let layers = VillageArt.lanternGlowLayers centerX centerY radius rings 255 214 150 peakAlpha + Assert.IsTrue(layers.Length > 0, "the glow must emit at least one layer") + Assert.AreEqual<(Rectangle * Color) list>(layers, VillageArt.lanternGlowLayers centerX centerY radius rings 255 214 150 peakAlpha) + + for (rect, color) in layers do + Assert.AreEqual<int>(1, rect.Height) + Assert.IsTrue(rect.Width >= 1) + Assert.IsTrue(int color.R <= int color.A && int color.G <= int color.A && int color.B <= int color.A) + Assert.IsTrue(int (max color.R (max color.G color.B)) < 250, "no ring may be near-white") + Assert.IsTrue(rect.X >= centerX - radius && rect.X + rect.Width <= centerX + radius + 1) + Assert.IsTrue(rect.Y >= centerY - radius && rect.Y <= centerY + radius) + + let covers px py = + layers |> List.exists (fun (rect, _) -> rect.X <= px && px < rect.X + rect.Width && rect.Y <= py && py < rect.Y + rect.Height) + Assert.IsTrue(covers centerX centerY, "the core must be covered") + Assert.IsFalse(covers (centerX + radius) (centerY + radius), "a square corner must stay dark: the glow is circular") + Assert.IsFalse(covers (centerX - radius - 5) centerY) + + [<TestMethod>] + member _.CharacterFeetSitOnTheTileGroundLine () = + Assert.AreEqual<int>(16, VillageArt.characterFeetOffset) + let destination = VillageArt.characterDestination { X = 100.0f; Y = 200.0f } (Vector2(0.0f, 0.0f)) + // Feet (sprite bottom) land half a tile below the tile centre; the sprite stays x-centred. + Assert.AreEqual<int>(216, destination.Y + destination.Height) + Assert.AreEqual<int>(100, destination.X + destination.Width / 2) + Assert.AreEqual<int>(32, destination.Width) + Assert.AreEqual<int>(48, destination.Height) + + [<TestMethod>] member _.RiverBandSpansTheSceneWithBankPathsAndCrossing () = let plan = VillageArt.sampleRenderPlan () let footprintOf (value: VillageArt.JiangnanRenderPlan) = VillageArt.planFootprint value |
