Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .agents/skills/lineup-image-to-csv/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
name: lineup-image-to-csv
description: Convert a festival lineup poster/flyer image into a schedule-import CSV. Use when asked to turn a lineup poster image into a CSV, digitize a festival schedule graphic, or prepare an image for the schedule import wizard.
allowed-tools: Read, Write, Bash(uv run --with pillow python .agents/skills/lineup-image-to-csv/crop.py *)
---

# Lineup image to CSV

Turn a poster into a CSV matching [`docs/schedule-import.md`](../../../docs/schedule-import.md)'s columns and semantics — read that doc for the column contract, matching rules, and type semantics; this skill only covers the image-reading process, not the CSV format itself.

## 0. Identify the poster's format

Posters are not one shape. Read the full image (or, for a lineup split across several images, one representative image) before picking a flow:

- **Grid poster** — boxed rows against a shared time ruler, one or more stage columns. Go to _1. Set up cropping_. Example: a Boom-style stage/day timetable.
- **Text-list poster** — a day header followed by plain `time — title — artist` lines, no boxes or ruler. Often one image per stage. Skip cropping entirely: read times and titles directly off the full image (re-crop only if resolution is too low to read text). Go straight to _5. Compile the CSV_, applying the midnight rule (_4_) where a day's lines cross `00:00`. Example: `ownspirit/*.jpeg`, one image per stage.
- **Name-only poster** — an artist list with no times or stages (e.g. a phase-announcement flyer). There is no schedule to extract. Leave `Stage`, `Date`, `Start Time`, `End Time` blank; only `Artists`, `Set Name`, `Type`, and `Description` apply. Capture groupings the poster itself marks — joint billings (`X | Y`), "2 sets", "Live", "B2B" — the same way step 5's multi-artist rule does. Example: `desert-base/2026/lineup-phase1.jpg`.

Steps 1-3 (cropping, grid mapping, border reads) apply only to grid posters. Step 4 (the midnight rule) and step 5 apply to every poster that has times.

## 1. Set up cropping

The Read tool shows the whole image but can't crop, and a full poster is too low-resolution to resolve individual grid lines. Crop with `.agents/skills/lineup-image-to-csv/crop.py` rather than ad hoc Pillow snippets, run from the repo root:

```
uv run --with pillow python .agents/skills/lineup-image-to-csv/crop.py --image <poster> --box L,T,R,B --out <scratchpad>/crop.png --scale 6
```

`uv run --with pillow` needs no venv, so nothing lands in the repo; write crop output to your scratchpad via `--out`. Its `--box`/`--scale`/`--out` args cover every crop this skill needs; see the script's docstring for usage.

## 2. Map the grid once

Read the full image first for an overview: stage names (poster columns), the days covered, and roughly where each act sits. Then find the exact x-ranges of the ruler column(s) and each stage column by cropping the header strip (where stage-name bands meet the ruler) and reading pixel offsets off the crop's reported scale factor.

Composite a ruler + stage-column crop (`crop.py --box <ruler-box> --box <stage-box>` pastes them side by side) before reading times — reading a stage column in isolation gives you box edges with no times to anchor them to.

## 3. Read times by border, not by label

**A box's border is its boundary**: a set's top border sits exactly on the gridline of its start time; its bottom border sits exactly on the gridline of its end time. The time label printed next to a row is that row's _start_ — never eyeball which label a box's text looks centered on, always find the row line the border itself touches.

Crop each stage (with its ruler) in overlapping chunks tall enough to show ~8-12 rows, with `--scale 5` to `--scale 8` — that's the zoom level where a border-to-label read is unambiguous. Move top to bottom through the full poster; re-crop tighter around any boundary that lands ambiguously between two labels in a first pass.

Do not try to locate borders by scanning pixel colors along a fixed x/y line — text glyphs produce the same light pixels as border strokes and give false positives. Visual crop-and-read is slower but is the only reliable method.

## 4. Apply the midnight rule

The date rail (e.g. "24.9 THURSDAY") flips to the next calendar date exactly at the `00:00` gridline, regardless of how late the festival's evening "feels" like it's still going. A set starting at `23:00` and ending at `01:00` keeps the _start_ row's date — the import pipeline's own end-before-start rollover (see `docs/schedule-import.md`) advances the end date automatically, so don't do it yourself in the CSV.

Confirm this against the poster itself (crop the date-rail text column near a day transition) rather than assuming — don't extrapolate from one confirmed transition to the rest of the poster without a spot-check, since posters are hand-laid and can be inconsistent.

## 5. Compile the CSV

One row per act (a grid box, a text-list line, or a name in a name-only lineup). Column contract is in `docs/schedule-import.md`; the image-reading-specific parts:

- **Multi-artist billing** (`X ft. Y`, `X B2B Y`, `X & Y`, `X | Y`): split into pipe-separated `Artists`, and keep the poster's exact original billing text as `Set Name`.
- **Subtitles** under a title (Live, Downtempo, a remix note, Opening Ceremony) go in `Description`.
- **Stage** column is the poster's stage header text verbatim (don't abbreviate or normalize it — fuzzy stage matching happens at import time, not here). Blank on a name-only lineup.
- Save the CSV alongside the source image, same base name (`festivals/<name>.jpg` → `festivals/<name>.csv`).

## Done when

- **Grid poster**: every act on every stage and day has a start and end time each confirmed by a border read (not inferred from a neighboring act's spacing or pattern).
- **Text-list poster**: every line under every day header on every stage image has a row, in the order it appears.
- **Name-only poster**: every name on the poster has a row.
- Every multi-artist row has both a split `Artists` field and a `Set Name`.

Before importing: dry-run the CSV through the schedule import wizard and check the diff review step — stage names fuzzy-match against existing DB stages, and a near-miss surfaces there, not here.
51 changes: 51 additions & 0 deletions .agents/skills/lineup-image-to-csv/crop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Crop region(s) out of a poster image, optionally compositing them
side by side, for close reading at zoom.

Usage:
crop.py --image IMG --box L,T,R,B [--box L,T,R,B ...] --out OUT [--scale N]

Multiple --box args are cropped independently, then pasted left to right
into one output image (e.g. pairing a ruler crop with a stage-column crop
so times and boxes read together). --scale resizes the result by an
integer factor with nearest-neighbor sampling, which keeps gridlines and
borders sharp instead of blurring them.
"""
import argparse

from PIL import Image


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--image", required=True)
parser.add_argument(
"--box", action="append", required=True, help="left,top,right,bottom (pixels)"
)
parser.add_argument("--out", required=True)
parser.add_argument("--scale", type=int, default=1)
args = parser.parse_args()

src = Image.open(args.image)
crops = [src.crop(tuple(int(v) for v in box.split(","))) for box in args.box]

width = sum(c.width for c in crops)
height = max(c.height for c in crops)
composite = Image.new(crops[0].mode, (width, height))
x = 0
for c in crops:
composite.paste(c, (x, 0))
x += c.width

if args.scale > 1:
composite = composite.resize(
(composite.width * args.scale, composite.height * args.scale),
Image.NEAREST,
)

composite.save(args.out)
print(f"{args.out}: {composite.width}x{composite.height}")


if __name__ == "__main__":
main()
3 changes: 0 additions & 3 deletions .candlekeep/.gitignore

This file was deleted.

1 change: 1 addition & 0 deletions .claude/skills/lineup-image-to-csv
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,23 @@ Ulien,,performance,,,,,Live Art
Borix,,performance,,,,,Live Art
Boaz Philosoph,,performance,,,,,Live Art
Maya Sierra,,performance,,,,,Live Art
Grouch,,music,,,,,
Grouch in Dub,,music,,,,,Solo
Persona,,music,,,,,
Melachim Alef Umelachim Bet,מלכים א' ומלכים ב',music,,,,,
Master Minded,,music,,,,,
Kojo On Acid,,music,,,,,
Oran Ray,Oran Ray Live,music,,,,,Live
Adi-J,,music,,,,,
Obsidian,,music,,,,,
Ovesh,,music,,,,,
DDAARR,DDAARR Live,music,,,,,Live
Aurora,Aurora Live,music,,,,,Live
Ty De La Luna,,music,,,,,
Yair Freiman,Yair Freiman Live,music,,,,,Live
Sesame,,music,,,,,
Hadar,,music,,,,,
Jooraffe,,music,,,,,
Buca Nonsense,,music,,,,,
Saadis,,music,,,,,
Kōrd|A-La-Rass,Vinyl Radio by Kōrd & A-La-Rass,music,,,,,
Binary file added desert-base/2026/lineup-phase2.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
68 changes: 68 additions & 0 deletions festivals/infected.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
Artists,Set Name,Stage,Date,Start Time,End Time,Description,Type
Nadav Dagon | Amana Women,Nadav Dagon ft. Amana Women,Main Stage,2026-09-24,19:00,20:00,,music
Daskal | Assaf Amdurski,Daskal ft. Assaf Amdurski,Main Stage,2026-09-24,20:00,21:00,,music
Knesiyat Hasechel | Eitan Reiter,כנסיית השכל & איתן רייטר,Main Stage,2026-09-24,21:00,22:00,,music
Infected Mushroom,,Main Stage,2026-09-24,22:00,01:00,3 Hour Live Band Show,music
Amigdala,,Main Stage,2026-09-25,12:00,14:00,,music
Shtuby,,Main Stage,2026-09-25,14:00,16:00,,music
Dekel,,Main Stage,2026-09-25,16:00,18:00,,music
Eitan Reiter,,Main Stage,2026-09-25,18:00,20:00,Live,music
Freedom Fighters,,Main Stage,2026-09-25,20:00,21:30,,music
Pettra,,Main Stage,2026-09-25,21:30,23:00,,music
Blastoyz,,Main Stage,2026-09-25,23:00,00:30,,music
Bliss,,Main Stage,2026-09-26,00:30,02:00,,music
Infected Mushroom,,Main Stage,2026-09-26,05:00,08:00,3 Hours Live Retro Set,music
Oi,,Ursul Records Stage,2026-09-24,22:00,23:30,(Omer Inbar) Opening Ceremony,music
K.O,,Ursul Records Stage,2026-09-24,23:30,01:00,,music
Millero,,Ursul Records Stage,2026-09-25,01:00,02:30,,music
Rebrn,,Ursul Records Stage,2026-09-25,02:30,04:00,,music
Dodi,,Ursul Records Stage,2026-09-25,10:00,11:00,,music
Ben Lipshitz,,Ursul Records Stage,2026-09-25,11:00,12:00,,music
Maayan Rahamim | Miss Albi,Maayan Rahamim B2B Miss Albi,Ursul Records Stage,2026-09-25,12:00,13:30,,music
Itayga,,Ursul Records Stage,2026-09-25,13:30,15:00,,music
Noya,,Ursul Records Stage,2026-09-25,15:00,16:30,,music
Sharon Attar,,Ursul Records Stage,2026-09-25,16:30,18:00,,music
T-Puse,,Ursul Records Stage,2026-09-25,18:00,19:30,,music
Mishell,,Ursul Records Stage,2026-09-25,19:30,21:00,,music
Clüb De Combat,,Ursul Records Stage,2026-09-25,21:00,22:30,,music
Omer Keinan,,Ursul Records Stage,2026-09-25,22:30,00:30,,music
Yannik Müller,,Ursul Records Stage,2026-09-26,00:30,02:00,,music
T-Puse | Omer Keinan,T-Puse B2B Omer Keinan,Ursul Records Stage,2026-09-26,02:00,04:00,Ursul Records,music
Argaman,,Fusion Culture Stage,2026-09-24,17:00,19:00,,music
Cutterpiller,,Fusion Culture Stage,2026-09-25,01:00,03:00,Israeli Remixes,music
Mendi,,Fusion Culture Stage,2026-09-25,08:00,09:30,Live,music
Shell-E,,Fusion Culture Stage,2026-09-25,09:30,11:00,,music
Essence Project,,Fusion Culture Stage,2026-09-25,11:00,12:30,,music
Doobo | Sushi,Doobo B2B Sushi,Fusion Culture Stage,2026-09-25,12:30,14:00,,music
Tomik | Leto CC,Tomik B2B Leto CC,Fusion Culture Stage,2026-09-25,14:00,15:30,,music
Yūgen,,Fusion Culture Stage,2026-09-25,15:30,17:00,,music
Dosh,,Fusion Culture Stage,2026-09-25,17:00,18:30,Live,music
Shtuby,,Fusion Culture Stage,2026-09-25,18:30,20:00,Downtempo,music
Mantismash,,Fusion Culture Stage,2026-09-25,20:00,21:30,,music
Bayawaka,,Fusion Culture Stage,2026-09-25,21:30,23:00,,music
Nueq,,Fusion Culture Stage,2026-09-25,23:00,00:30,,music
Symbolico,,Fusion Culture Stage,2026-09-26,00:30,02:00,,music
Cutterpiller,,Fusion Culture Stage,2026-09-26,02:00,03:30,,music
Stulip | Katnoa,Stulip B2B Katnoa,Fusion Culture Stage,2026-09-26,03:30,05:00,,music
Sunset Riders,,Pulse Stage by Tuborg,2026-09-24,18:00,19:30,,music
Duskii,,Pulse Stage by Tuborg,2026-09-24,19:30,20:30,,music
Mementomor,,Pulse Stage by Tuborg,2026-09-25,00:00,01:00,,music
Broosh,,Pulse Stage by Tuborg,2026-09-25,01:00,02:00,,music
Libaax,,Pulse Stage by Tuborg,2026-09-25,02:00,03:00,,music
Gigi,,Pulse Stage by Tuborg,2026-09-25,03:00,04:00,,music
Ranyo,,Pulse Stage by Tuborg,2026-09-25,10:00,11:00,,music
Nevo,,Pulse Stage by Tuborg,2026-09-25,11:00,12:00,,music
Eukai,,Pulse Stage by Tuborg,2026-09-25,12:00,13:00,,music
Chuka,,Pulse Stage by Tuborg,2026-09-25,13:00,14:00,,music
Adiros,,Pulse Stage by Tuborg,2026-09-25,14:00,15:00,,music
Yoti,,Pulse Stage by Tuborg,2026-09-25,15:00,16:00,,music
Chemical Drive,,Pulse Stage by Tuborg,2026-09-25,16:00,17:00,,music
Artifex,,Pulse Stage by Tuborg,2026-09-25,17:00,18:00,,music
Moosh On,,Pulse Stage by Tuborg,2026-09-25,18:00,19:30,,music
Haymus,,Pulse Stage by Tuborg,2026-09-25,19:30,21:00,,music
Tetrakørd,,Pulse Stage by Tuborg,2026-09-25,21:00,23:00,,music
Indepth,,Pulse Stage by Tuborg,2026-09-25,23:00,01:00,,music
Fvmestic,,Pulse Stage by Tuborg,2026-09-26,01:00,02:00,,music
Detune,,Pulse Stage by Tuborg,2026-09-26,02:00,03:00,,music
Multiverse,,Pulse Stage by Tuborg,2026-09-26,03:00,04:00,,music
The Rabbit Hole,,Pulse Stage by Tuborg,2026-09-26,04:00,05:00,,music
Binary file added festivals/infected.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading