DEV Community

Focss
Focss

Posted on

Reading 240 bus-puzzle boards out of YouTube thumbnails

I run Bus Traffic Fever Guide, a fan site with 240 level pages for the mobile game Bus Traffic Fever!. Each page has a board image, a difficulty tier, and an embedded walkthrough. The board image is the level's YouTube thumbnail at 1280x720. I wrote scripts/extract-board-data.py to measure that thumbnail. The script uses NumPy and Pillow, with no model and no GPU, and writes src/data/board-data.json.

The page copy should describe the board a reader can see. src/lib/level-content.ts takes the measured board record and the level record as separate inputs, so the text does not infer advice from the level number. A level number tells you nothing about the passenger queue. The thumbnail does, within limits.

A fixed crop fails on the first template

The gameplay capture is a narrow phone-shaped column in the middle of the thumbnail. The game logo sits to its left and the level card to its right. Some templates move the phone column or use a 640x480 canvas. A crop with hardcoded coordinates would include the logo on one layout and cut the playfield on another.

The first pass locates the column before any crop. find_phone_column computes the fraction of non-black pixels per column with a.sum(axis=2) > 90, keeps columns above 0.60, and finds contiguous runs at least 100 columns wide. It picks the run containing the horizontal centre. When no run contains the centre, it takes the widest run. A thin divider can split one capture into two, so the code merges neighbouring runs separated by up to 6 px while the combined width stays under 700 px. Level 107 needed that merge for a 4 px divider.

Find the phone sheet, then the puzzle screen

The lower part of the phone capture has a parking lot and a flat light garage sheet. find_garage_top looks for rows where pixels have a max channel between 85 and 195, saturation below 0.35, and that test covers more than 25% of the row. It tracks a continuous run of at least 40 such rows and returns the first row after the run, constrained to 24% through 66% of the image height. The fallback is 44% of the height. Everything above that line is the live puzzle screen.

The passenger sample stops 100 px above the garage top. Bus paint starts around that height, and a passenger mask that included it would report bus colours as queue colours. The horizontal extent comes from parking_span, which finds the garage strip in the row below the "+" icons and trims to the 2nd and 98th percentiles of its pixels. That removes the outer thumbnail margins before the lattice is built.

Sample a 39 by 39 lattice

The passenger area is sampled on a fixed 39 by 39 lattice. A 39 by 39 grid gives 1,521 cells, enough to compare colour shares across boards without trying to recover the game's internal passenger grid. The script samples the median colour of each patch:

def sample_cells(a, x0, x1, y0, y1, nx, ny):
    """Median colour of the middle of every lattice cell."""
    cells = np.zeros((ny, nx, 3), dtype=np.float32)
    width = x1 - x0
    height = y1 - y0
    for j in range(ny):
        cy0 = y0 + int(j * height / ny)
        cy1 = y0 + max(int(j * height / ny) + 1, int((j + 1) * height / ny))
        for i in range(nx):
            cx0 = x0 + int(i * width / nx)
            cx1 = x0 + max(int(i * width / nx) + 1, int((i + 1) * width / nx))
            patch = a[cy0:cy1, cx0:cx1]
            if patch.size == 0:
                cells[j, i] = 0
                continue
            cells[j, i] = np.median(patch.reshape(-1, 3), axis=0)
    return cells
Enter fullscreen mode Exit fullscreen mode

The median handles the dark gaps between passengers, highlights on individual sprites, and anti-aliased tile edges. A mean would mix a gap pixel into a flat fill and shift the cell toward black or grey. The median returns the dominant flat colour, which is what the eye reads as the passenger colour. The script also masks UI regions before classification: the refresh button at the top left, the pause button at the top right, the level badge, and the decorative bushes in the bottom corners.

Measure the backdrop from the ring

The board background is the flat outer-ring colour: the tile floor on indoor boards, or grass on outdoor boards. The script finds it from the modal colour on the outer two-cell ring. Ring colours are quantized into 16-step buckets, the most common bucket wins, and the background is the median of the ring cells in that bucket. A cell within a summed RGB distance of 35 from that colour is marked as background and excluded from the colour groups.

def detect_background(cells):
    """Flat backdrop: the modal colour on the sample's outer ring."""
    ny, nx = cells.shape[:2]
    ring = np.zeros((ny, nx), dtype=bool)
    ring[:BG_RING, :] = ring[-BG_RING:, :] = True
    ring[:, :BG_RING] = ring[:, -BG_RING:] = True
    ring_colours = cells[ring]

    quant = (ring_colours // 16).astype(int)
    keys = quant[:, 0] * 256 + quant[:, 1] * 16 + quant[:, 2]
    key = Counter(keys.tolist()).most_common(1)[0][0]
    background = np.median(ring_colours[keys == key], axis=0)
    mask = np.abs(cells - background).sum(axis=2) < BG_MATCH
    return background, mask
Enter fullscreen mode Exit fullscreen mode

The quantization keeps small lighting variation from splitting the ring into several near-identical buckets. The median then gives one clean background colour for the JSON. On Level 115 the background is light grey at 50.8% of the sample. On Level 221 it is green at 46.1%.

Classify hue families

Cells with saturation below 0.25 or value below 0.18 are skipped. The rest go into hue families: red around 345 to 10 degrees, orange and brown below 38, yellow below 65, green below 160, cyan below 190, blue below 255, purple below 295, and pink above that. The code splits red from brown at value 0.55 and orange from brown at 0.52. Shading folds into the family, so a dark red highlight still counts as red.

Two small families can be shading of a larger neighbour. A pink family below 3% of the sample is folded into red when red has at least twice as many cells. A cyan family below 2% is folded into blue under the same ratio. Families below 1% of the sampled cells are dropped, which keeps a stray plant or UI pixel from becoming a palette entry. Across 240 boards the result is 3 to 9 colour groups per board, with a median of 6.

Find what reaches the entrance

Colour groups become an integer label map, ordered by cell count. The script runs an 8-connected BFS over each label. The boarding entrance is a zone at the bottom centre: columns from 18% to 82% of the width, and the bottom three rows that contain coloured cells. A component that touches that zone is open at the start. Every other component is cut off by whatever sits between it and the entrance.

For each colour, locked is the percentage of its cells in cut-off components. by lists the neighbouring colours that touch those components, up to four, stored as indices into the colour list. The index order becomes names on the page. If no component reaches the zone, the script falls back to the component nearest the bottom centre, so a board with a slightly short loop still reports a front group. The f field is the colour of the component closest to the entrance.

Level 115 shows the output shape. Orange is 11.4% and nearest the entrance, with 79% of its cells cut off. The record for that colour looks like this:

{"hex":"#EAA34E","name":"orange","pct":11.4,"cells":173,"locked":79,"by":[2,4,1,3]}
Enter fullscreen mode Exit fullscreen mode

Those four indices resolve to yellow, pink, purple, and cyan. Yellow is 6.2% and 100% cut off. Pink is 5.0% and 99% cut off. Cyan is 6.0% and only 8% cut off, so it sits close to the open path. The page can state those relationships without claiming a tap order.

Read seven station slots

The station row has seven play slots. Some templates add a VIP token in an eighth column on the left, and the row shifts right when it is present. The green "+" icons locate the row. has_vip_token checks a yellow patch in the left column with more than 300 matching pixels. In a standard 300 to 360 by 650 to 800 px capture, that token moves the rightmost slot centre from 271.5 to 289.0, and the slot pitch stays at 34.5 px. A scaled template estimates the pitch from the "+" spacing and snaps 32 to 37 px to the same value. The seven centres are generated from the right edge:

    centres = [right - pitch * k for k in range(7)]
Enter fullscreen mode Exit fullscreen mode

Each slot is sampled for a bus body with this mask:

        mask = ((ss > 0.35) & (vv > 0.22)) | (vv < 0.30)
        area = int(mask.sum())
        coverage = area / float(mask.size) if mask.size else 0.0

        if area < min_area or coverage < MIN_BUS_COVERAGE:
            continue
Enter fullscreen mode Exit fullscreen mode

Saturated paint and near-black bodies both pass. An empty slot falls below 30% coverage or the minimum area. The bounding box must be at least 18 by 16 px before scaling, which rejects the small "+" icons and most confetti. A final solid-base test requires the bottom centre of the mask to be at least 50% filled, so a hollow bay with a "just left" glow or passengers spilling from the road is rejected even when confetti covers the roof. The VIP token occupies the eighth column and is handled as a row offset before sampling, so it never enters a slot mask.

Duplicate colours are aggregated: two yellow buses become one entry with count: 2. Across the 240 frames the row holds 0 to 6 buses, with a median of 3. Ten frames have no parked bus. The colour totals across all frames are blue 55, red 89, green 74, orange 56, pink 67, purple 99, yellow 79, brown 62, cyan 61, and black 8.

What this cannot tell you

The input is one mid-game still frame. The script cannot see the full rotation order of the passenger loop. It cannot see the order the player tapped, the starting fleet, or how the game resolves a stalled queue. It reports the frame: colour shares, the open colour, the distance from the entrance, and the buses in the row. It never invents a tap order.

White and grey passenger groups are not counted. The white loop markings, tile floor, and white badge share the same low-chroma range, so a neutral group would add a false palette entry to almost every board. The colour percentages therefore describe coloured groups only. The scattered bus field below the station row is also outside the sample; only parked station buses are counted.

The geometry is fixed to the current thumbnail template. A new layout with different side margins, a different garage band, or a different slot pitch would break the fixed geometry. The 39 by 39 lattice is a sampling grid; the game's real passenger grid stays out of the output. A share of 11.4% refers to the sampled lattice cells, and the exact passenger count is unknown.

What the records feed

Each record goes into src/data/board-data.json, and src/lib/level-content.ts turns it into page text for that level. Level 115 gets a sentence about orange at 11.4% and nearest the entrance, yellow and pink cut off, and three green buses plus one blue in the row. Level 221 gets blue at 8.7% with 1% locked and a row of two brown, two purple, one pink, and one red. A generic template cannot produce those sentences because the numbers differ by level. The 240 level pages on the site are built from these records, one board at a time.

Top comments (0)