File size: 6,051 Bytes
37e3d5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
#!/usr/bin/env python3
"""Tests for Plan 1.3 §4.5 reference admission + the shared pHash (§3.1 signal).

Pure stdlib synthetic PNGs (RGB, 8-bit, non-interlaced — the format read_png parses
natively). No PIL/numpy.
"""

from __future__ import annotations

import struct
import sys
import tempfile
import unittest
import zlib
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "stage1_intake"))
sys.path.insert(0, str(ROOT / "_shared"))

from check_reference_admission import check_admission, largest_component_fraction  # noqa: E402
from image_hash import hamming, phash_from_image  # noqa: E402

PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"


def write_rgb_png(path: Path, w: int, h: int, pixel_fn) -> None:
    def chunk(tag: bytes, data: bytes) -> bytes:
        c = struct.pack(">I", len(data)) + tag + data
        return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)

    raw = bytearray()
    for y in range(h):
        raw.append(0)
        for x in range(w):
            raw += bytes(pixel_fn(x, y, w, h))
    ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)
    png = (
        PNG_SIGNATURE
        + chunk(b"IHDR", ihdr)
        + chunk(b"IDAT", zlib.compress(bytes(raw), 9))
        + chunk(b"IEND", b"")
    )
    path.write_bytes(png)


def _centered_block(cx0, cy0, cx1, cy1, fg=(200, 30, 30), bg=(255, 255, 255)):
    def fn(x, y, w, h):
        return fg if (cx0 <= x < cx1 and cy0 <= y < cy1) else bg
    return fn


class ImageHashTest(unittest.TestCase):
    def _split_image(self, vertical: bool, size=64):
        px = []
        for y in range(size):
            for x in range(size):
                lo = (x if vertical else y) < size // 2
                v = 30 if lo else 220
                px.append((v, v, v, 255))
        return px

    def test_identical_hamming_zero(self):
        px = self._split_image(vertical=True)
        self.assertEqual(hamming(phash_from_image(64, 64, px), phash_from_image(64, 64, px)), 0)

    def test_brightness_shift_barely_changes_hash(self):
        px = self._split_image(vertical=True)
        bright = [(min(255, v + 20),) * 3 + (255,) for (v, _g, _b, _a) in px]
        bright = [(t[0], t[1], t[2], t[3]) for t in bright]
        self.assertLessEqual(hamming(phash_from_image(64, 64, px), phash_from_image(64, 64, bright)), 4)

    def test_different_structure_differs_substantially(self):
        a = phash_from_image(64, 64, self._split_image(vertical=True))
        b = phash_from_image(64, 64, self._split_image(vertical=False))
        self.assertGreaterEqual(hamming(a, b), 8)


class ReferenceAdmissionTest(unittest.TestCase):
    def setUp(self):
        self.dir = Path(tempfile.mkdtemp())

    def test_admits_coherent_centered_subject(self):
        p = self.dir / "good.png"
        write_rgb_png(p, 200, 200, _centered_block(50, 50, 150, 150))
        v = check_admission(p, viewpoint="side")
        self.assertTrue(v["admitted"], v["reasons"])
        self.assertGreater(v["provenance"]["largestComponentFraction"], 0.9)

    def test_rejects_no_isolable_subject(self):
        # all-background: build_foreground_mask's fallback treats every pixel as
        # foreground → coverage saturates → "no background to segment" rejection.
        p = self.dir / "blank.png"
        write_rgb_png(p, 200, 200, lambda x, y, w, h: (255, 255, 255))
        v = check_admission(p)
        self.assertFalse(v["admitted"])
        self.assertTrue(any("coverage" in r for r in v["reasons"]), v["reasons"])

    def test_rejects_tiny_resolution(self):
        p = self.dir / "tiny.png"
        write_rgb_png(p, 40, 40, _centered_block(10, 10, 30, 30))
        v = check_admission(p)
        self.assertFalse(v["admitted"])
        self.assertTrue(any("resolution floor" in r for r in v["reasons"]), v["reasons"])

    def test_rejects_fragmented_mask(self):
        # 16 separated 16×16 red squares on white → coverage ~0.10 (in band) but the
        # largest connected blob is a tiny fraction of foreground → coherence rejection.
        def fn(x, y, w, h):
            for gx in (10, 55, 100, 145):
                for gy in (10, 55, 100, 145):
                    if gx <= x < gx + 16 and gy <= y < gy + 16:
                        return (200, 30, 30)
            return (255, 255, 255)
        p = self.dir / "frag.png"
        write_rgb_png(p, 200, 200, fn)
        v = check_admission(p)
        self.assertFalse(v["admitted"])
        self.assertTrue(any("coherence" in r for r in v["reasons"]), v["reasons"])

    def test_rejects_duplicate(self):
        p = self.dir / "dup.png"
        write_rgb_png(p, 200, 200, _centered_block(50, 50, 150, 150))
        first = check_admission(p, viewpoint="front")
        self.assertTrue(first["admitted"], first["reasons"])
        again = check_admission(p, viewpoint="front", against_hashes=[first["provenance"]["pHash"]])
        self.assertFalse(again["admitted"])
        self.assertTrue(any("duplicate" in r for r in again["reasons"]), again["reasons"])

    def test_rejects_undecodable_cleanly(self):
        # a truncated/bogus file must be a clean rejection, never a crash/exit-2.
        p = self.dir / "bogus.png"
        p.write_bytes(PNG_SIGNATURE + b"\x00\x01\x02garbage")
        v = check_admission(p)
        self.assertFalse(v["admitted"])
        self.assertTrue(any("cannot decode" in r for r in v["reasons"]), v["reasons"])

    def test_largest_component_fraction_math(self):
        # one solid 4×4 blob in a 6×6 grid → fraction 1.0
        w = h = 6
        mask = [(1 <= i % w <= 4 and 1 <= i // w <= 4) for i in range(w * h)]
        self.assertAlmostEqual(largest_component_fraction(mask, w, h, grid=6), 1.0, places=3)
        # two separated single cells → largest fraction 0.5
        mask2 = [False] * (w * h)
        mask2[0] = True
        mask2[w * h - 1] = True
        self.assertAlmostEqual(largest_component_fraction(mask2, w, h, grid=6), 0.5, places=3)


if __name__ == "__main__":
    unittest.main(verbosity=2)