Skip to main content

Ichiwatashi(一渡始)

Deterministic measurement and parametric construction for pixel art.

中文说明:README.zh-CN.md


What this is, and why measurement comes first

A generate-judge-refine loop needs a judge before it needs a generator. This is the judge, plus the construction and palette and frame pieces that a loop turns out to need.

The order is not a preference. Pointing the first version of the judge at a sprite known to be good falsified two of its own assumptions immediately:

Assumption What the measurement said
"Fewer isolated pixels is better" A real 72乘113 fighting-game frame had 10.1% isolated pixels — 312 of them — against 1.35% in a hand attempt. The good sprite looked worse because the count was absolute and the sprites were different sizes. As a rate the good sprite is dirtier still, and correctly so: at 21 colours across 546 clusters, those pixels are the detail — single-pixel highlights, eyes, cloth texture. So the metric needs a rate and a cluster shape to tell noise from detail, and a rate-only rule raises a false positive on known-good art.
"One consistent outline colour is disciplined" The good sprite scored 0.662 and the hand attempt 0.991 — so the metric was inverted. And then the inversion was wrong as well, which is the more useful half of the lesson. Pointed at 161 real TH155 sprites that number has a median of 0.003 — they are all flat — while TH123 sits at 0.726. Two generations of correct work. So it was never measuring discipline: it was measuring how many colours the character's palette has, which is why one pipeline scored futo 0.055 and hijiri exactly 0.000. It is now two quantities, outline_variation and outline_predictability, and outline_style() names the style (flat / systematic / noisy) without grading it. corpus/AESTHETICS.md records the published technique this corresponds to — 「セルアウト」, where an outline is the body's own colour darkened, which is what a near-1.0 predictability means.

Both of those would have been trained into a model as ground truth, and neither is visible without a known-good sample to point at. The second one shows the limit of that remedy too: pointing the metric at good art is what revealed the inversion, and pointing it at a second generation of good art is what revealed that the inversion had only swapped which convention got called correct. One corpus is a sample of one art direction, so the reference is now keyed by generation — see calibration.py.

And then the thresholds were measured rather than chosen

One known-good sprite falsifies an obviously wrong rule. It cannot set a threshold — so the judge was pointed at 120 real sprites (8bpp character frames from th123a.dat, 24–128 px, seeded sample from 3853 eligible). The distributions live in ichiwatashi/calibration.py with their provenance:

metric p05 p25 median p75 p95
colours_used 16 22 26 31 37
isolated_rate 0.074 0.099 0.116 0.134 0.160
outline_variation 0.578 0.658 0.726 0.801 0.855
bbox_fill 0.345 0.458 0.538 0.589 0.652
largest_cluster_rate 0.039 0.063 0.090 0.136 0.228

Only 1 of the 120 raises any note now, which is the property worth keeping: a report that fires on good work is a report that gets ignored.

Two things fell out of the distribution that a single sample could not have shown:

  • The old isolated-pixel rule fired above 5%, which is below the fifth percentile of real sprites. Real art sits at 7.4–16%. Both of the 32乘32 attempts were wrong in opposite directions — one at 15.4% (fragmented) and one at 1.35% (too coherent, and flagged by a high bounding-box fill instead). A "lower is better" rule can only ever see one of those two failures.
  • Real sprites use 26 colours at the median. Both attempts used 10 and 12, which is a real gap and now gets said out loud.

And a third corpus showed the metrics measure a convention as well as art

The judge was pointed at 40 frames of a pixel-art animation from a different artist and a different decade. Raw, they measured bbox_fill 1.000 and outline_variation 0.000 — raising "the silhouette is a block" and "the outline is flat" — and 40 of 40 were flagged.

Both notes were false, and for a reason the tool had no way to see: those frames are opaque and fill their canvas, with the subject on a background rather than on transparency. bbox_fill and outline_variation cannot be measured on such an image at all — they read 1.000 and 0.000 whatever is drawn. After the background was made transparent and each frame cropped to its subject, the same frames measured 0.642 and 0.564, both inside the reference range.

So judge now recognises a rendered frame and says so, instead of producing two confident notes about the export rather than the drawing. That is the same failure as the 5% threshold and the 35% flag rate: a report that fires on good work gets ignored, and the way to find these is to point the tool at work that is already known to be good.

With the convention matched, the two corpora can be compared at all:

metric TH123 (n=120) animated pixel art (n=40)
isolated_rate 0.116 (0.074–0.160) 0.057 (0.036–0.078)
outline_variation 0.726 (0.578–0.855) 0.564 (0.245–0.703)
bbox_fill 0.538 (0.345–0.652) 0.642 (0.613–0.685)
largest_cluster_rate 0.090 (0.039–0.228) 0.149 (0.060–0.191)

Two differences survive, and one row is deliberately missing: palette_size was forced to 16 when the second corpus was reconstructed from a lossy source, so that number is the reconstruction's and not the artist's. A metric that a processing step sets is not evidence about the art, which is the same distinction the merging sampler exists to make.

Notes are phrased as unlike the reference rather than wrong, and the range used is p05–p95 — so by construction about one good sprite in ten sits outside it. That rate is deliberate: it keeps the note meaning something.


Install

pip install -e .

Pillow is a hard dependency: a tool that reads and writes images and cannot open a PNG is broken, not degraded.

Figures

ichiwatashi figures sprite.png --out figures/ --scale 6 --colours 16

Five pixel-art figures — the sampled palette, the silhouette, the outline study, this sprite's metrics against the shipped reference bands, and (with --corpus DIR) the corpus source distribution. Every one is drawn with this repository's own tools: builder's shapes and font's glyphs, never a plotting library. The family rule is that this project's assets are pixel art, and for a measuring tool that has a second edge — if a figure cannot be drawn with the tool's own verbs, the tool has a gap, and importing a drawing library would hide it.

The output path is one way only: a canvas at one pixel per pixel, then an integer NEAREST upscale. A figure that had been resampled would be lying about the pixels it shows, so the renderer is asserted to invent no colour: every pixel in a figure comes from the figure's own key table.

Two layout decisions were forced by reading the pixels back rather than by looking:

  • A swatch needs a field-coloured pad. The ring on each swatch is its own darker step, which is the outline_predictability relation drawn instead of tabulated — and for a near-black colour that derives a near-black ring, so one swatch in a sixteen-colour board had no edge at all. The pad guarantees a boundary whatever the colours are.
  • A bar chart needs a span per row. Three judge metrics on one shared scale measure 43.0, 0.115 and 0.41; the first filled the plot and the other two were invisible. Each row is now drawn inside its own reference band with the shipped reference marked on it, which is the comparison tools/calibrate.py prints as text.

Panels: a frame that verifies itself

NTVI assigns this repository the shared pixel layer — font, palette, panel layout — which the other tools embed. Something three other projects will embed has to be verifiable by itself, so ichiwatashi/panel.py is a description, a renderer and a pixel check rather than a GUI:

ichiwatashi panel out.png --width 96 --title "Ichiwatashi" --scale 4
out.png  96x40 logical, written at 384x160
  3840 pixel(s) checked, 0 disagreed with the layout

draw and check walk one definition. _role_at decides what each pixel carries; draw paints it and check compares against it. A first version had the renderer paint "a dark rectangle then a light face" while the checker derived the expected role from a diagonal split — two renderers agreeing with each other verifies nothing. What the check now verifies is the pixels against the layout, which is what it is for.

And it caught two real things while being built.

  • 90 problems on a correct panel — the title glyphs are drawn over the title bar, so those pixels are legitimately not the bar's colour. check(..., title=...) now steps over exactly the glyph box, computed from the same metrics draw used, so the rest of the bar is still verified.
  • A 64-pixel panel titled "Ichiwatashi" drew "Ichi wa.ta.sk" — the bar had 58 pixels and the word needs 65, so the panel displayed a different word and saved successfully. That is the same failure as a figure whose label ran off its canvas. A title that does not fit is now refused, and the message names both numbers.

The defaults derive from the font: title_h = 9 because a 5x7 glyph needs a pixel of clearance above and below. A title bar that cannot hold a glyph is not a title bar.

What makes it a window rather than a panel

Two things, and both are checkable properties rather than rendering choices:

  • Size tiers. compact 72x30, normal 96x40, wide 160x48 — one layout at three sizes, which is only true because every tier passes the same check. A test asserts that, and it is what keeps three tiers from becoming three layouts.
  • Activity. The title bar's colour is the visible record of whether a window is the active one, so title and title_inactive are two roles, not a tint applied at render time. Measured: a panel drawn active and checked as inactive reports 751 problems, all in the title bar. A tint would have been a detail no test could see.
ichiwatashi panel out.png --tier compact --title "Ichi" --scale 3
t-compact.png  compact tier 72x30, written at 216x90, active
  2160 pixel(s) checked, 0 disagreed with the layout

The window draws its own frame, including the close button

The system's title bar is not used, because an undecorated pixel-art window is the requirement and a system-drawn bar is neither pixel art nor ours. root.overrideredirect(True) removes the chrome, and the two things the system was providing are reimplemented from panel.layout:

  • Dragging — the title bar's rectangle is the hit area, so a window at any tier drags from its own bar.
  • Closing — the panel draws a 5x7 red X at the right end of its bar, and clicking it closes the window. The title's available width is reduced by the mark (panel.title_room is the one definition of that sentence); without it a title that fits can run underneath the mark while the check still passes, because the mark's pixels are asserted separately — an invisible collision in the one place it matters.

Input: a model, not a toolkit

"A keyboard-operable window" sounds like a GUI problem and is mostly not one. What a window does with a key — which control is next, whether Enter activates or closes, whether Escape backs out — is a small state machine, and that is the part that can be wrong. The toolkit only delivers the keystroke.

So ichiwatashi/keys.py contains no toolkit at all:

$ ichiwatashi panel --tier compact --title "Ichi" --controls 3 \
    --dispatch "focus_next focus_next activate" --text
  controls: 3, starting at 0
    focus_next   -> moved      index=1
    focus_next   -> moved      index=2
    activate     -> activated  index=2

########################################################################
#++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++#
#+--------------------------------------------------------------------##
#+---###--------------------------------------------------------------##
...
active  focus 3/3

One character is one logical pixel, so the interface can be compared as a string — which a toolkit window cannot be: what a test can see of it is whatever the toolkit exposes, and that is not the pixels. The geometry comes from panel.layout, because two independent ideas of where a border goes is the defect this repository already fixed once.

Two things the split bought, both measured:

  • The escape-sequence order is now a test. \x1b[Z starts with \x1b, so a table tried in the wrong order reads every arrow key as Escape plus stray characters — the symptom is a window that closes when you press Up. Longest-first is asserted.
  • A binding that never fired. parse_key("q") returns char:q while the table says q, so "q": "quit" sat there and never triggered — measured, pressing q returned ignored. A char: key not found verbatim is now looked up by its letter.

Measured availability before choosing any of this: tkinter present, msvcrt present, curses and termios absent, terminal 80 by 24 — so a real window is reachable; what was decided first is what to verify.

A panel that crosses a language boundary

NTVI's four tools are not all written in one language: Volcano Separator is PowerShell and Node. So "embed the shared pixel layer" cannot mean import, and must not become a runtime dependency — the family rule is source is shared, dependency is not. What crosses a language boundary is data.

ichiwatashi panel --tier normal --title "Ichiwatashi" --export panel.json
panel.json  panel exported: normal tier 96x40, 8 role(s), checksum c6ffbe5d401d2d2c

ichiwatashi panel --verify panel.json
panel.json  matches this build: 3840 pixel(s) agree, checksum c6ffbe5d401d2d2c

The file carries the layout rectangles, the roles, the key each role uses, a colour and a character keyed the same way, the close mark's pixels, and the title's available width — plus a checksum over canonical JSON (sorted keys, no spaces) so a reader in another language can recompute it. A file edited after export is refused by its own checksum.

A null character means "drawn as font glyphs, not as one mark", which is the one role that has no single character, and the file says so rather than leaving it out.

The clear ground is not a colour

palette.sample merges near-duplicates first — a sampler that does not merge measures the encoder, not the art — and it counts only opaque pixels. A first version converted to RGB before counting, which turned every transparent pixel into #000000; on a real 207x179 sheet that black became the most frequent entry in the palette of a sprite that does not contain it.

And the palette command names which parameter limited the result, in four cases rather than two, because "fewer than --count" has three different causes:

18 distinct colour(s) in the drawing
merged to 4 within 24 per channel -- the threshold is what limited this, not --count 8
  lower --merge to see more; 0 measures the encoder rather than the art
all 18 colour(s) this drawing uses (--count 40 allows more; merging within 4 reduced none)
4 colour(s) -- the first 4 of the merged set, capped by --count

The third case deliberately does not say what merging would have done: --count 4 --merge 24 can return four colours for both reasons at once, and naming the wrong cause is worse than naming none.

Palette roles

palette.roles(image) says what job each colour does, and it judges that by the relation a colour stands in, not by where it sits:

#927BCE  fill
#000000  fill
#0C0009  outline

Two colours a reader cannot tell apart as hex values — #000000 and #0C0009 — are different roles, which is the point.

Only the outline role is measured confidently. The TODO entry asked for 皮肤 / 头发 / 服装 / 描边 / 高光 slots; four of those need semantics a pixel grid does not carry, because nothing in the data says which region is skin rather than cloth. highlight is deliberately not assigned: the obvious criterion (most neighbours are darker) put 224 colours into that role across 20 sprites, four to seventeen per sprite — that is what a gradient looks like at every step, not a role.

The criterion is the (fill, outline) pair relation judge._outline already builds, and the positional alternative was measured first and failed: "touches transparency" produced no outline role at all on 16 of 20 real sprites, while outline_predictability simultaneously read ~1.0. An outline colour is also used for the interior contours between hair locks, so most of its occurrences never touch the background.

Over all 160 sprites, every one has at least one outline colour; the count is 1 for 100 of them, 2 for 38, 3 for 4, 5 for 3, 6 for 14 and 7 for 1. The most common single outline colour is (26, 0, 35) in 80 sprites, and (12, 0, 9) in 37 — so it is not one constant across the generation.

Frame drift, two ways

Timeline.drift() compares each frame's bounding-box corner against its placement: it measures how far the silhouette's edge moved. Timeline.aligned_shift() measures how far each frame must be moved to sit on the one before it, which is the translation the drawing actually underwent — and it sees motion the first one cannot, because a breathing chest or a turning head keeps the outline and moves everything that matters.

content drift relative to the anchor: 0 x 0 pixel(s)
aligned shift between frames: +1,+0, +1,+0, +1,+0, +1,+0, +1,+0
  spread 0 x 0  (0 means one steady offset; non-zero means the motion is not a translation)

Two things about that output are deliberate. The per-pair offsets are the useful reading (a steady value is speed; a spread is a stutter). And the spread is the refusal: a genuine translation returns one offset for every pair, so a non-zero spread means the motion is not a translation and the offsets should not be read as distances. A best fit that lands on the edge of the search window is reported as 0, 0 with a note, because a truncated search is not a measurement.

And exact answers the question that flag cannot. Frames are an integer grid, so a genuine translation matches every changed pixel and leaves a residual of zero. Measured as a matched fraction: a one-pixel translation gives 1.0000 at every window size, while a 90-degree rotation gives 0.58 to 0.74 as the window widens and a horizontal flip 0.83 to 0.87. Nothing that is not a translation reaches 1.0, and the translation reaches it regardless of the window — which is what makes this a judgement rather than a note. So the CLI reports it as a count:

exactness: all 5 pair(s) are exact integer translations

And when a fit ran out of search window it says so, instead of offering the flagged 0, 0 as an answer:

exactness: none of the 5 pair(s) is a translation -- every fit ran out of search window, so no offset was
found and none is reported

Read one thing as a caution: fitting every opaque pixel is overwhelmed by whatever is not moving. On a fixture where a 16-pixel accent slides inside a fixed 330-pixel silhouette, the whole-frame fit correctly returns (0, 0) for every pair. The default differences the pair first and fits only the pixels that changed, which is why it finds the motion.

Frame timing, read rather than invented

ichiwatashi frames DIR --delays FILE takes per-frame durations from a file that already carries them, and frames.read_delays() does the reading.

Timing does not have to be designed by hand. A per-frame delay table is a normal thing for a container to store — うごイラ is "frames plus a delay table", and GIF, APNG and WebP each store a duration per frame. So the question is which container to read, not where timing could possibly come from.

Two sources:

  • an image file that stores durations (GIF / APNG / WebP)
  • a plain text table, one number per line — commas and whitespace both work
  • an animation archive (.zip), if it carries a table — metadata.json, ugoira_meta.json, frames.json, delays.txt or animation.json, in the pixiv metadata shape, as a bare frames array, or as a plain list of numbers

A native うごイラ archive has no timing to give. pixiv's ZIP contains the frames and nothing else; the delays come from the API (/v1/ugoira/metadata → ["ugoira_metadata"]["frames"], each entry {"file": ..., "delay": ...}). So an archive without a table is reported as such, with its frame count — which proves it was read rather than mistaken for something else:

native.zip holds 4 frame(s) and no delay table -- pixiv's own archives keep timing in the API metadata,
so it is not in this file

Two limits, asserted rather than described:

  • GIF keeps its delays in hundredths of a second, so it can never report finer than 10 ms. Asking a GIF for 33 ms gives back 30. That is the format, not the reader.
  • a table that does not fit the frame count is refused, not padded. Three values against six frames has no honest resolution: broadcast one value and the animation is wrong everywhere; fill a default and the invented number is indistinguishable from a measured one afterwards.

A single-frame image reports no duration rather than a guess. That case was a real bug in the first version — a still PNG skipped the image branch because "has more than one frame" was used as the test for "is an image", then fell through to the text reader and came back with five numbers parsed out of its binary header.

GIF, and what it cannot hold

Timeline.to_gif() refuses by default when the frames cannot be represented without loss, and gif_losses() reports the same numbers before any file exists. GIF has no alpha channel and a 256-colour table, so:

  • partly transparent pixels become either fully opaque or fully clear;
  • more than 256 distinct colours are quantised.
REFUSED: out.gif would not be what was drawn:
  192 pixel(s) are partly transparent, which GIF cannot represent: they become either fully opaque or fully clear
Use to_apng for real alpha and no colour limit, or pass allow_loss=True to write it anyway.

The two losses are not equally acceptable, so they can be allowed one at a time.

kind what it costs
transparency a partly transparent pixel becomes opaque or clear — information that cannot be recovered
palette above 256 colours the palette is quantised — a loss of degree: shapes stay, colours approximate
ichiwatashi frames DIR --gif out.gif --allow palette
REFUSED: out.gif would not be what was drawn:
  transparency: 288 pixel(s) are partly transparent, which GIF cannot represent: they become either fully
  opaque or fully clear
Use to_apng for real alpha and no colour limit, or allow the loss with allow={"transparency"} /
{"palette"} / allow_loss=True.

gif_losses()["kinds"] says which losses are present, so a caller can ask before deciding, and a misspelled kind is refused by name — otherwise "I allowed it and it still refused" reads as a broken refusal rather than a typo. LOSS_KINDS holds two names, and a test asserts that count: a third candidate ("a frame mixes opaque and transparent pixels") turned out to flag every ordinary sprite and was removed.

--allow-loss (or allow_loss=True) is the caller saying "I know" — the same shape as judge(anyway=True) and the frames-count refusal. The refusal leaves no file behind, because a file that opens and is not what was drawn is worse than no file: the reader has nothing to go on.

One thing that is not a loss, and the first version of this check got it wrong: a frame using both fully opaque and fully transparent pixels. That is what every ordinary sprite frame looks like — drawn pixels on a transparent ground — and it is exactly what GIF's single transparent index represents correctly. Only a partly transparent pixel is unrepresentable.

Grid round trip

ichiwatashi grid sprite.json --out sprite.grid.json     # a shape script's canvas, as editable JSON
#   ... edit sprite.grid.json by hand ...
ichiwatashi ungrid sprite.grid.json --out sprite.png --emit-script sprite.rebuilt.json

The form is {width, height, bg, palette, pixels}, where pixels is one string per row. Colors are written #RRGGBB, or #RRGGBBAA when the palette carries alpha, or transparent.

Reading a grid back validates it, and the validation is the reason from_grid exists rather than being a call to from_rows. Each refusal names something that would otherwise be a wrong picture far from the file that caused it: a missing or non-positive size, rows that are not strings, a row count or row width that disagrees with the header, a colour that is neither hex nor a known spelling — and the quiet one, a character with no palette entry. An unknown key renders as nothing, so a typo in a hand-edited file would leave a hole and raise no error anywhere. That check is only possible because the grid carries its palette.

A round trip is asserted to be pixel-identical and byte-identical after rendering, because the point of the feature is the TODO entry's own words: a grid could be written out and corrected by hand, and there was no way to read it back, so hand work could not return to the tool.

History

{"op": "rect", "x0": 2, "y0": 2, "x1": 17, "y1": 9, "key": "r"},
{"op": "undo"},
{"op": "rect", "x0": 6, "y0": 2, "x1": 13, "y1": 9, "key": "r"}

A shape script can step back over its own earlier steps and try something else in place, so one run shows both attempts instead of two files. {"op": "undo", "count": 2} steps back twice; redo steps forward. A refused undo is printed, because a run that said the same thing either way would let a reader believe an edit had been rolled back when the canvas never moved.

The API is Canvas.undo() / redo() / history() / state() / clear_history(), and a caller looping over put for a dynamically computed region can group it with with canvas._shape("name"):.

Undo stores a snapshot of the cells rather than a diff, and that is the acceptance test talking. This project's rule is that state is reconstructable rather than trusted; a diff-based undo would need every verb to be exactly invertible, and shade and outline both read the canvas while writing to it, so an inverse of those is not a local operation. A snapshot needs nothing of the kind: undo puts the cells back to a state that was observed, so "pixel-identical to what it was" holds by construction. The cost was measured before choosing — a 512 by 512 canvas is 262,144 single-character strings and copying that list takes about a millisecond.

Use

Measure

ichiwatashi judge sprite.png
sprite.png  32x32  474 opaque pixel(s)
  palette          12 colour(s)
  pixel scale      1  (1 is normal for hand-authored art)
  isolated pixels  73  (15.40%)
  clusters         100, largest 46 (9.7%), median 1
  outline variation 0.088 over 102 border pixel(s)  flat -- known-good sprites vary this with the local colour
  bounding box     26x32, 57% full
  note: the outline is almost one flat colour (variation 0.09); known-good sprites vary it with the local colour

--fail-on-note exits non-zero, so it can be a check rather than a report.

Sample a palette

ichiwatashi palette sheet.png --count 16 --merge 24 --saturate 2.0 --crop 64,0,482,941

--merge is the point. A 40-frame JPEG animation reported 25,398 distinct RGB values, and the head of that list was #005D77 followed by #005E77, #015C77, #005D76 — one colour and its compression halo. Merging within 24 levels per channel turns that back into the ~10 colours the artist used. --merge 0 is available and measures the encoder instead.

Two boards, and they are different objects:

  • material board — sampled from a reference; what the drawing is made of
  • accent board — chosen by role; what makes the character recognisable

A frequency-sampled board is a distribution, and a distribution is mostly the region two characters share. Sampled from two character sheets: zero shared colours and a median separation of 0.303, but a minimum of 0.026 — the pair that close being white hair against a near-black outline, which both characters have. The minimum is what gets confused at small sizes.

Build

ichiwatashi build sprite.json --out sprite.png --preview sprite_x8.png

A shape script:

{
  "width": 32, "height": 32,
  "palette": {".": "transparent", "h": "#600018", "w": "#FDFDFD", "k": "#2A222C"},
  "steps": [
    {"op": "cone",  "y0": 0, "y1": 6, "w0": 1, "w1": 11, "key": "h"},
    {"op": "disc",  "cx": 15.5, "cy": 14, "r": 5.5, "key": "w"},
    {"op": "outline", "key": "k"}
  ]
}

Why shapes rather than placing pixels. Authoring a grid by hand requires already having a finished silhouette in mind, which is the skill being learned. Two hand attempts at 32乘32 produced blocks that were not recognisable. The same sprite as a cone, a disc and a trapezoid was readable on the second attempt — because getting a proportion wrong became editing three numbers instead of re-placing hundreds of pixels, and iterating is what converges.

That is not a claim that shapes beat hands; a person placing pixels deliberately will beat this. An author who cannot yet do that will not, and this gets them to something that communicates.

The outline pass is not decoration. Without it a silhouette dissolves into the background at small sizes. It is automatic here for the same reason it exists: it is a rule, not a decision.

Frames

ichiwatashi frames ./frames --out loop.apng --duration 40

Reports drift: how far the content moves relative to the anchor. That is the number that says whether an animation will jitter.

from ichiwatashi import Timeline, even_durations, with_hold

tl = Timeline(64, 64, anchor=(32, 60))
for im, pivot in frames:
    tl.add(im, pivot=pivot)

# Fix the total, then buy smoothness with frames and weight with duration.
durations = even_durations(800, 20)
durations = with_hold(durations, 12, 360)     # a one-shot motion wants one long hold

Two real animations were measured and they solve the same problem oppositely:

fixed canvas per-frame crop + stored pivot
anchor implicit (the canvas corner) stored per frame
cost texture — the background was 75.7% of each frame the pivot must be right, or the animation drifts
good for small looping sprites large one-shot motions with big displacement

How a claimed pivot is checked. Not by looking at the number. The same frames were laid out twice on the same canvas — once by the stored pivot, once by frame centre — and compared. Pivot alignment put the feet on one level line; centre alignment floated them. A pivot that is claimed but never compared against the alternative is a number with a story attached.

Timing, measured from a series by one author: nine frames at 70 ms, twenty at 40 ms, forty at 20 ms — frame count and per-frame duration moving in opposite directions while the total stayed between 0.6 and 1.1 seconds. A one-shot motion additionally wants a single long hold, which is how the same survey turned up 360 ms and 500 ms outliers sitting alone among 60–170 ms neighbours.


What this measures, and what it refuses to

The judge has a subject, and it now says so instead of printing numbers for anything. A metric applied to something that does not have the property being measured returns not applicable, and nothing in the arithmetic distinguishes that from a clean result. Two cases made this a code change rather than a paragraph:

  • silhouette_share is 1.000 on a single-colour form. That reads as "a uniform edge" and means nothing — every edge pixel is that colour by construction.
  • The whole metric set on a 5乘7 bitmap font. Measured over all 58 glyphs of volcano-separator's self-drawn pixel font: colours_used 1 against a band of 16–37, isolated_rate 0.000 against 0.074–0.160. Every glyph came back flagged as unlike any reference generation. A font and a fighting sprite are not the same kind of object, and nothing had said so. (tools/probe_font_glyphs.py is that measurement.)

So an input whose short side is under 24 px is refused with exit code 2, distinct from 1, which means "measured, and a note was raised". Those are different answers and a caller has to tell them apart.

The threshold is measured, not chosen. The 160-sprite reference corpus has a smallest short side of 62 px (p05 91, median 123), so 24 refuses a 7 px glyph by a factor of three while clipping nothing real — checked, not assumed: TestScope::test_the_gate_clips_nothing_in_the_reference_corpus runs the judge over the whole corpus and asserts none of it raises.

--anyway steps past the gate and prints the numbers as uncalibrated. It exists for the case where the caller knows what they are measuring; it is not a way to make a warning go away, because there is no warning — there is a refusal, and overriding it is a decision the caller has to make on purpose.

What this deliberately does not do is recognise "a font panel" or any other non-sprite subject. A screenshot of text is large, passes the gate, and still is not a sprite. A gate can catch the shapes there are measurements for; anything subtler is a stated limit rather than a silent pass.


What is deliberately not here

No model, and no training. Most of what makes pixel art read as pixel art is countable from the pixels, and a countable answer is faster, explainable and does not drift. Only judgements like "is this face appealing" need a learned model, and that is a small part of the whole. The pieces that are measurable are all here; the pieces that are not are not pretended into existence.

Layout

ichiwatashi/judge.py the measurements, each with the correction it carries
ichiwatashi/builder.py shapes, and the outline pass
ichiwatashi/palette.py merging sampler, saturation, the two boards
ichiwatashi/frames.py canvas, pivot, timing, GIF/APNG
test/ 22 assertions, several of them pinning a mistake so it cannot return

Licence

MIT.

Metadata

Release files for ichiwatashi 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ichiwatashi 0.1.0
File Size Uploaded Provenance
ichiwatashi-0.1.0.tar.gz 196.5 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for ichiwatashi 0.1.0
File Interpreter ABI Platform
ichiwatashi-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 315.1 kB

Release files / ichiwatashi-0.1.0.tar.gz

Download URL ichiwatashi-0.1.0.tar.gz
Size 196.5 kB
Tags Source
SHA-256 checksum
How to use checksums
128a98620a237b2b1da9f9b26d75788837ef4e916d50ce4a7da5dea0c9173852
BLAKE2b-256 checksum
How to use checksums
195d0084c9b601377652b1cef5a546b330875ef1d93b0d68f780873f8a4aaa1b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.

Transparency log

Release files / ichiwatashi-0.1.0-py3-none-any.whl

Download URL ichiwatashi-0.1.0-py3-none-any.whl
Size 118.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f86de286dd2690928e1f1b016161f67f1a727abf82e00e31b113244bb6c00908
BLAKE2b-256 checksum
How to use checksums
ab9307a473ba76cd2a27a2661e9ee6d37aabba35e1e1e2bd9d9a6fa741e53b39
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.

Transparency log

Security for ichiwatashi 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for ichiwatashi-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page