File size: 9,944 Bytes
4b98524
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
"""Counting a frame at several resolutions, and noticing when the count runs away.

A detector reads a fixed 640 px input. Hand it a 4,000 px photograph of a paddock
and every animal is downsampled before the network sees it; hand it one ninth of
that photograph and each animal arrives four times larger. So the same detector
returns a different count depending on how the frame is cut up, and **the way it
changes as you cut finer is the measurement that matters**.

Two things fall out of that, and the second is the important one.

**The count gets better.** Slicing a frame into overlapping tiles and detecting
in each one recovers animals that whole-frame inference loses to downsampling.
This is the standard trick for small objects in large images.

**The count says whether it can be trusted.** In a frame the detector can
actually read, the count stops moving: a paddock with a dozen cattle returns
about a dozen at one tile, at four, and at nine, because there was nothing left
to find. In a broiler house it never stops moving β€” every finer cut finds more
birds, because there are always more birds hidden behind the ones in front.

That is the difference between a count and a sample, measured rather than
guessed. The old guard asked whether the boxes it *had* were small, which is a
question about the animals the detector found and says nothing about the ones it
missed β€” on a shed of a thousand birds it saw twenty large foreground birds,
concluded the frame was sparse, and published twenty.
"""

from __future__ import annotations

from dataclasses import dataclass

from PIL import Image

from app.detectors.base import Detection, Detector

#: How much neighbouring tiles overlap, as a share of tile size. An animal
#: sitting exactly on a cut would otherwise be two half-animals, each too
#: partial to detect. Cross-tile NMS then removes the duplicates the overlap
#: creates.
TILE_OVERLAP = 0.20

#: IoU above which two boxes are the same animal. Looser than the within-tile
#: NMS threshold, because the same animal seen in two tiles is cropped
#: differently in each and the boxes never align exactly.
MERGE_IOU = 0.55

#: Intersection over the *smaller* box's area, above which the smaller box is a
#: part of the larger one rather than a second animal.
#:
#: This is the threshold that makes tiling safe, and leaving it out is how the
#: first attempt turned one cow into three. A cow filling the frame is cut into
#: quarters by a 2x2 grid, and the detector obligingly finds a cow in each
#: quarter; those four quarter-boxes barely overlap *each other*, so IoU keeps
#: all four. Each is almost entirely inside the whole-frame box, so containment
#: removes them.
#:
#: 0.85 rather than something lower because two animals standing one behind the
#: other genuinely overlap: on the evaluation set the near animal's box covered
#: up to three quarters of the far animal's. Merging those would trade a
#: duplicate for a lost animal.
MERGE_CONTAINMENT = 0.85

#: Tile grids, coarse to fine. 1 is whole-frame. Stopping at 3 is a cost
#: decision: 1 + 4 + 9 inferences already takes seconds on a CPU, and a frame
#: still finding new animals at 3x3 is a shed β€” the answer there is that no
#: count exists, not that a fourth grid would find it.
LEVELS: tuple[int, ...] = (1, 2, 3)


@dataclass(frozen=True)
class Level:
    """Everything found at this grid **and every coarser one**.

    Accumulating rather than replacing is what makes the comparison between
    levels mean something: each level is a superset, so a level that adds
    nothing new is a level that found nothing new, and the count can only rise.
    It is also what keeps the whole-frame box of a large animal in the pool to
    absorb the fragments a fine grid makes of it.
    """

    grid: int
    detections: list[Detection]

    @property
    def count(self) -> int:
        return len(self.detections)


def _crops(size: tuple[int, int], grid: int) -> list[tuple[int, int, int, int]]:
    width, height = size
    if grid == 1:
        return [(0, 0, width, height)]

    step_x, step_y = width / grid, height / grid
    pad_x, pad_y = step_x * TILE_OVERLAP, step_y * TILE_OVERLAP
    boxes = []
    for row in range(grid):
        for column in range(grid):
            x0 = max(0, int(column * step_x - pad_x))
            y0 = max(0, int(row * step_y - pad_y))
            x1 = min(width, int((column + 1) * step_x + pad_x))
            y1 = min(height, int((row + 1) * step_y + pad_y))
            boxes.append((x0, y0, x1, y1))
    return boxes


def _overlaps(
    a: tuple[float, float, float, float], b: tuple[float, float, float, float]
) -> tuple[float, float]:
    """`(IoU, intersection over the smaller area)` for two boxes."""
    ax0, ay0, ax1, ay1 = a
    bx0, by0, bx1, by1 = b
    x0, y0 = max(ax0, bx0), max(ay0, by0)
    x1, y1 = min(ax1, bx1), min(ay1, by1)
    overlap = max(0.0, x1 - x0) * max(0.0, y1 - y0)
    if overlap <= 0.0:
        return 0.0, 0.0
    area_a = (ax1 - ax0) * (ay1 - ay0)
    area_b = (bx1 - bx0) * (by1 - by0)
    union = area_a + area_b - overlap
    smaller = min(area_a, area_b)
    return (
        overlap / union if union > 0 else 0.0,
        overlap / smaller if smaller > 0 else 0.0,
    )


def _merge(detections: list[Detection], frame_area: float) -> list[Detection]:
    """One animal, one box, whichever tile found it.

    **Largest box first**, which is the ordering the containment rule needs: the
    whole animal has to be in the kept set before its fragments are tested
    against it. Score order β€” the usual choice for NMS β€” would let a confident
    fragment claim the animal and leave its siblings unmatched.

    Boxes come back in frame coordinates, and `area_fraction` is recomputed
    against the whole frame: a bird covering a quarter of its tile covers a
    thirty-sixth of the picture, and everything downstream reasons about the
    picture.
    """
    def area(d: Detection) -> float:
        x0, y0, x1, y1 = d.box
        return (x1 - x0) * (y1 - y0)

    kept: list[Detection] = []
    for detection in sorted(detections, key=area, reverse=True):
        duplicate = False
        for other in kept:
            if other.label != detection.label:
                continue
            iou, containment = _overlaps(other.box, detection.box)
            if iou > MERGE_IOU or containment > MERGE_CONTAINMENT:
                duplicate = True
                break
        if duplicate:
            continue
        kept.append(
            Detection(
                label=detection.label,
                score=detection.score,
                box=detection.box,
                area_fraction=area(detection) / frame_area,
            )
        )
    kept.sort(key=lambda d: d.score, reverse=True)
    return kept


def _raw(detector: Detector, image: Image.Image, grid: int) -> list[Detection]:
    """Every box one grid produced, in frame coordinates, unmerged."""
    width, height = image.size
    gathered: list[Detection] = []
    for x0, y0, x1, y1 in _crops((width, height), grid):
        tile = image if grid == 1 else image.crop((x0, y0, x1, y1))
        for detection in detector.detect(tile):
            tx0, ty0, tx1, ty1 = detection.box
            gathered.append(
                Detection(
                    label=detection.label,
                    score=detection.score,
                    box=(tx0 + x0, ty0 + y0, tx1 + x0, ty1 + y0),
                    # Recomputed by `_merge`; a tile-relative fraction here
                    # would be wrong by the square of the grid.
                    area_fraction=detection.area_fraction,
                )
            )
    return gathered


def detect_at(detector: Detector, image: Image.Image, grid: int) -> list[Detection]:
    """Run one grid on its own. Used by the tests and by nothing else."""
    width, height = image.size
    return _merge(_raw(detector, image, grid), float(width * height))


def pyramid(
    detector: Detector,
    image: Image.Image,
    subject_classes: tuple[str, ...],
    growth_tolerance: float,
    levels: tuple[int, ...] = LEVELS,
) -> list[Level]:
    """Count at successively finer grids, stopping as soon as the count settles.

    Returns every level that was run, coarsest first, each holding the merged
    result of every grid up to and including its own. The caller decides what
    the sequence means; this function only refuses to spend inferences it does
    not need β€” a frame that has settled is not going to unsettle, and the common
    case is a farmer photographing six animals.
    """
    frame_area = float(image.size[0] * image.size[1])
    gathered: list[Detection] = []
    results: list[Level] = []

    for grid in levels:
        gathered.extend(_raw(detector, image, grid))
        results.append(Level(grid=grid, detections=_merge(gathered, frame_area)))
        if len(results) >= 2 and converged(
            results[-2], results[-1], subject_classes, growth_tolerance
        ):
            break
    return results


def subject_count(level: Level, subject_classes: tuple[str, ...]) -> int:
    return sum(1 for d in level.detections if d.label in subject_classes)


def converged(
    coarser: Level, finer: Level, subject_classes: tuple[str, ...], tolerance: float
) -> bool:
    """Whether cutting the frame finer stopped finding new animals.

    Growth is measured against the coarser count, so it is a proportion rather
    than a difference: three more animals out of six means the frame was not
    read, three more out of sixty means it was.
    """
    before = subject_count(coarser, subject_classes)
    after = subject_count(finer, subject_classes)
    if before == 0:
        # Nothing at the coarse grid. Converged only if the finer grid agrees,
        # otherwise the coarse pass simply could not see the animals.
        return after == 0
    return (after - before) / before <= tolerance