From 3268d16e210f88f880a3b16d70a4c1b8cf6defa0 Mon Sep 17 00:00:00 2001 From: Fedir <98510937+fMindstormer@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:08:53 -0500 Subject: [PATCH 01/11] Re-search: redraw a color whose region falls into parts The greedy search takes one shape over a whole region (two circles stacked on an oval become one tall box plus patches), because that shape fixes the most pixels at its step. A new re-search neighborhood splits a color's region where its distance to the edge comes apart, climbs one shape per part against that part alone, refills, and keeps the result only if the finished program scores better. On cssynth's own scores (no Chromium yet), total J drops 4.6% on the development split (9 targets better, none worse) and 3.4% on training (4 better, none worse), for 5-8% more compile CPU. 2024-02-27 goes from 695 characters to 200. bench/evaluate.py gains a no-parts method. Also: inset drops the lengths CSS repeats (inset:100px). Co-Authored-By: Claude Opus 5.5 --- README.md | 17 +++++----- bench/evaluate.py | 10 ++++-- cssynth/codegen.py | 16 ++++++++- cssynth/improve.py | 46 ++++++++++++++++++++----- cssynth/search.py | 79 +++++++++++++++++++++++++++++++++++++++++++ docs/method.md | 48 +++++++++++++++++++++----- tests/test_codegen.py | 10 +++++- tests/test_search.py | 46 ++++++++++++++++++++++++- 8 files changed, 242 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 53c5e97..7d93f2f 100644 --- a/README.md +++ b/README.md @@ -321,14 +321,15 @@ tests/ turns as motifs rescued some petals but lengthened other targets. A filter learned from outcome-labeled proposals didn't transfer to structures it hadn't seen (docs/method.md). Otherwise the search still goes one shape at - a time, and the re-search only repairs its choices locally. Two light circles stacked on an oval (2024-02-27) become one tall - rounded box plus patches, because the box fixes more pixels than either - circle, and a climb from one circle grows back into the box. The lotus is - drawn with polygons where a golfer uses three rotated leaves. Comparing - whole programs (two circles plus a cheap shadow against a box plus - patches) is the next step. A beam over partial J is not enough on its - own: the two-circle branch trails for two steps before it pulls ahead, - and the climbs never propose a lone circle to start it. + a time. The re-search now compares whole programs in one case: a color + whose region falls into parts (two circles stacked on an oval, a cloud of + discs) is redrawn with one shape per part, finished, and kept if the + finished program scores better. On cssynth's own scores this lowered the + development total by 4.6% and made no target worse (docs/method.md); it + hasn't been measured in Chromium yet. Choices that don't show up as parts + of one color still go one step at a time, and a beam over partial J + doesn't fix that: on 2024-02-27 the two-circle branch trails for two + steps before it pulls ahead. - **Reuse.** Turned copies of a box share a rule and mirrored copies of a polygon share a `clip-path`, but nothing yet uses `::before`/`::after` to repeat a shape, or mirrors a rounded box. diff --git a/bench/evaluate.py b/bench/evaluate.py index 4276ed8..9243dc4 100644 --- a/bench/evaluate.py +++ b/bench/evaluate.py @@ -30,7 +30,7 @@ ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "bench")) -from cssynth import compiler, search, simplify # noqa: E402 +from cssynth import compiler, improve, search, simplify # noqa: E402 BENCH = ROOT / "bench" FIDELITY = 99.0 @@ -67,6 +67,12 @@ def run(path): return run +def _no_parts(path): + """The default compiler without re-searching colors in parts (v0.6.0).""" + improve.PARTS = False + return compiler.compile_image(path) + + def _fixes(path): """Everything but motifs.""" search.MOTIFS = set() @@ -96,7 +102,7 @@ def _learned(path): # name: path -> (code, info, model) METHODS = {"full": _full, "fast": _fast, "base": _base, "base-more": _base_more, - **{f"full-{n}": _full_more(n) for n in (16, 20)}, "fixes": _fixes, + **{f"full-{n}": _full_more(n) for n in (16, 20)}, "fixes": _fixes, "no-parts": _no_parts, "nest": _motifs(["nest"]), "nest-sym": _motifs(["nest", "symmetric"]), "all-motifs": _motifs(["nest", "symmetric", "seeded"]), "nest-allcolors": _motifs(["nest"], same_color=-1), "learned": _learned} diff --git a/cssynth/codegen.py b/cssynth/codegen.py index 77946b9..5e3aa22 100644 --- a/cssynth/codegen.py +++ b/cssynth/codegen.py @@ -403,10 +403,24 @@ def size_encodings(e: dict) -> list[dict]: x, y = float(e["left"]), float(e["top"]) right, bottom = W - x - w, H - y - h d = {k: v for k, v in e.items() if k not in ("left", "top", "width", "height")} - out.append({"inset": " ".join(px(v) for v in (y, right, bottom, x)), **d}) + out.append({"inset": sides(y, right, bottom, x), **d}) return out +def sides(top, right, bottom, left) -> str: + """Four lengths in CSS's shorthand order, dropping the ones it repeats: + a missing left is the right, a missing bottom the top, a missing right + the top.""" + v = [top, right, bottom, left] + if left == right: + v.pop() + if bottom == top: + v.pop() + if right == top: + v.pop() + return " ".join(px(a) for a in v) + + def _insert_after(d: dict, key: str, item) -> dict: out = {} for k, v in d.items(): diff --git a/cssynth/improve.py b/cssynth/improve.py index d3176f5..78fefab 100644 --- a/cssynth/improve.py +++ b/cssynth/improve.py @@ -11,17 +11,27 @@ * one polygon, refilled without polygons for its color * every shape of one color, refilled as before * every shape of one color, refilled without polygons for that color + * every shape of one color whose region falls into parts (two circles + stacked, a cloud of discs), redrawn as one shape per part and refilled: + the greedy search takes the shape over the whole region, because it + fixes more pixels at that step than any one part, and patches the rest Each is tried once, in a single pass that continues from the improved stack. +Every refill is finished (refined, simplified, emitted) before it is compared, +so a choice that loses its step can win on the whole program. """ from __future__ import annotations -from .search import greedy, refine +import numpy as np +from scipy import ndimage + +from .search import add_parts, greedy, parts, refine from .shapes import Gradient from .simplify import simplify SCREEN = 1.3 # refills scoring worse than this times the best skip simplify +PARTS = True # redraw colors whose regions fall into parts (see split_colors) def _colors(pal, bg): @@ -30,18 +40,35 @@ def _colors(pal, bg): return [c for c in range(len(pal)) if c != bg] + [bg] -def neighborhoods(shapes): - """(key, shapes kept, colors to draw without polygons, description).""" +def neighborhoods(shapes, split=None): + """(key, shapes kept, colors to draw without polygons, part masks to draw + first or None, description). split: {color: part masks} (see split_colors).""" for i, s in enumerate(shapes): if s.poly: - yield ("polygon", s.poly), shapes[:i] + shapes[i + 1:], (s.color,), f"polygon {i} without polygons" + yield ("polygon", s.poly), shapes[:i] + shapes[i + 1:], (s.color,), None, f"polygon {i} without polygons" colors = sorted({s.color for s in shapes}) for c in colors: - yield ("color", c), [s for s in shapes if s.color != c], (), f"color {c}" + yield ("color", c), [s for s in shapes if s.color != c], (), None, f"color {c}" for c in colors: if any(s.poly for s in shapes if s.color == c): - yield (("color", c, "no polygons"), [s for s in shapes if s.color != c], (c,), + yield (("color", c, "no polygons"), [s for s in shapes if s.color != c], (c,), None, f"color {c} without polygons") + for (c, whole), masks in (split or {}).items(): + yield (("parts", c, whole), [s for s in shapes if s.color != c], (), masks, + f"color {c} in {len(masks)} {'whole ' if whole else ''}parts") + + +def split_colors(T, colors): + """{(color, whole): masks of the parts of its regions} for the colors + with a region that falls into parts (see search.parts), both ways.""" + out = {} + for c in colors: + lab, n = ndimage.label(T == c, structure=np.ones((3, 3))) + for whole in (False, True): + masks = [m for i in range(1, n + 1) for m in parts(lab == i, whole)] + if masks: + out[c, whole] = masks + return out def improve(score, pal, bg, T, shapes, rho, min_gain, per_color, bbox_starts, outlines, @@ -50,13 +77,16 @@ def improve(score, pal, bg, T, shapes, rho, min_gain, per_color, bbox_starts, ou shapes by length as well as pixels (see search.greedy); motifs: propose motifs while refilling. Returns the improved stack.""" best, best_J = shapes, score(shapes) + split = split_colors(T, [c for c in _colors(pal, bg) if c != bg]) if PARTS else {} tried = set() while True: - nb = next((nb for nb in neighborhoods(best) if nb[0] not in tried), None) + nb = next((nb for nb in neighborhoods(best, split) if nb[0] not in tried), None) if nb is None: return best - key, kept, no_polygons, what = nb + key, kept, no_polygons, seed, what = nb tried.add(key) + if seed is not None: + kept = add_parts(T, bg, kept, key[1], seed, min_gain) stack = greedy(T, bg, _colors(pal, bg), min_gain=min_gain, per_color=per_color, bbox_starts=bbox_starts, outlines=outlines, start=kept, no_polygons=no_polygons, pal=pal, rho=rho if by_length else None, motifs=motifs)[min_gain] diff --git a/cssynth/search.py b/cssynth/search.py index dbd333b..3eb754c 100644 --- a/cssynth/search.py +++ b/cssynth/search.py @@ -569,6 +569,85 @@ def tri_area(a, b, c): return poly if len(poly) == k else None +PART_RISE = 3.0 # px a part's core must rise above the level where it splits off +MIN_PART = 200 # pixels in a part + + +def parts(region: np.ndarray, whole=False) -> list[np.ndarray]: + """The parts of a region that overlapping or touching shapes drew (two + circles stacked, a cloud of discs), as masks; [] when it doesn't split. + The region is thinned, by distance to its edge, until it falls apart; + each core counts if it rises PART_RISE above the level t where it split + off (so a bar's ragged ridge doesn't), and the lowest level with the most + cores is used. A part is what discs of radius t centered in its core + cover (the region opened at t, split between the cores), so a thinner + piece with no core of its own, like the bar under a cloud's discs, is + left to the search. whole: every pixel goes to the core nearest it + instead, which keeps thin ends (a leaf's tips) with their part.""" + ys, xs = np.nonzero(region) + if len(xs) < 2 * MIN_PART: + return [] + sl = (slice(ys.min(), ys.max() + 1), slice(xs.min(), xs.max() + 1)) + r = np.pad(region[sl], 1) + D = ndimage.distance_transform_edt(r) + best = None + for t in np.arange(1.0, D.max()): + lab, n = ndimage.label(D > t) + if n < 2: + continue + idx = np.arange(1, n + 1) + keep = idx[ndimage.maximum(D, lab, idx) - t >= PART_RISE] + if len(keep) >= 2 and (best is None or len(keep) > len(best[0])): + best = keep, lab, t + if best is None: + return [] + keep, lab, t = best + dist, (iy, ix) = ndimage.distance_transform_edt(~np.isin(lab, keep), return_indices=True) + owner = np.where(whole | (dist <= t), lab[iy, ix], 0) + out = [] + for i in keep: + m = (r & (owner == i))[1:-1, 1:-1] + if m.sum() >= MIN_PART: + full = np.zeros_like(region) + full[sl] = m + out.append(full) + return out if len(out) >= 2 else [] + + +def add_parts(T, bg, shapes, c, masks, min_gain=MIN_GAIN): + """shapes with one shape of color c added per part mask, largest first, + each climbed on its own part's pixels only (so a disc stays a disc instead + of growing over its neighbors), at the depth where it fixes the most.""" + shapes = list(shapes) + for p in sorted(masks, key=lambda m: -int(m.sum())): + belows, frees = _layers(T, bg, shapes) + ys, xs = np.nonzero(p) + x0, y0 = int(xs.min()), int(ys.min()) + comp = p[y0:ys.max() + 1, x0:xs.max() + 1] + tables = [] + for k in range(len(belows)): + A, B = _masks(T, belows[k], c, frees[k]) + tables.append((k, A & p, B)) + pool = [] + for cand in candidates(c, comp, x0, y0): + cx, cy, m = coverage(cand) + if m is None: + continue + hh, ww = m.shape + g, k = max((np.count_nonzero(A[cy:cy + hh, cx:cx + ww] & m) + - np.count_nonzero(B[cy:cy + hh, cx:cx + ww] & m), -k) for k, A, B in tables) + pool.append((g, -k, cand)) + best = None + for _, k, cand in sorted(pool, key=lambda t: -t[0])[:TOP_STARTS]: + _, A, B = tables[k] + s, g = hill_climb(cand, A, B, sums=area_sums(A, B)) + if best is None or g > best[0]: + best = (g, k, s) + if best is not None and best[0] >= min_gain: + shapes.insert(best[1], best[2]) + return shapes + + def _masks(T, C, c, free=None): A = (T == c) & (C != c) B = (C == T) & (T != c) diff --git a/docs/method.md b/docs/method.md index 2657ec3..50c4653 100644 --- a/docs/method.md +++ b/docs/method.md @@ -148,6 +148,37 @@ polygon (and forbid polygons of its color), or every shape of one color remaining stack (in the winning variant's mode), refine, simplify, and keep the result if J drops. +A third neighborhood redraws a color whose region falls into parts. The +region is thinned by its distance to the edge until it comes apart; each +piece whose core rises at least 3 px above that level is a part (the level +with the most such cores wins). Two circles stacked on an oval give two +parts; a cloud gives one per disc. Each part is either what discs of the +split radius cover inside it (clean circles) or every pixel nearest its core +(which keeps a leaf's tips), and both are tried. One shape is climbed per +part, at its best depth, scored only against that part so that a disc +can't grow over its neighbor; then the usual greedy refill takes over. The +greedy search never makes this choice on its own: the shape over the whole +region fixes more pixels at that step than any one part, and a climb from +one circle grows back into it. Here the choice is judged on the finished +program instead. Putting the same splits into the greedy search as +candidates had raised the total J by 3% (see "What doesn't work yet"). + +Scored with cssynth's own rasterizer, which needs no browser (the variants +were run once per target, then re-searched both ways): + +| Split | Total J without parts | With parts | Characters | Targets better / worse | Re-search CPU | +|---|---|---|---|---|---| +| Development (66) | 60,046 | 57,305 (−4.6%) | 22,894 → 21,901 | 9 / 0 | +21% | +| Training (48) | 72,966 | 70,491 (−3.4%) | 15,348 → 15,181 | 4 / 0 | +33% | + +The whole compile takes 5–8% more CPU. The two circles on the oval +(2024-02-27) go from 695 characters to 200 (human: 481): a rounded box, a +circle and its `box-shadow` copy. The cloud goes from 512 to 380, and the +lotus from 551 to 484 (human: 418) with fewer wrong pixels. Three +development targets (the oval, the cloud, 2024-03-30) become shorter than +the human code, counting characters only; the match has yet to be checked +in Chromium. + ### Length-aware clean-up The search maximizes pixels fixed; the clean-up minimizes J. Its moves: @@ -465,15 +496,14 @@ get rare cases right, not the average. ## What doesn't work yet -- **Search order.** Re-search fixes some greedy mistakes, but a lotus of - three overlapping leaves is still drawn with polygons where a golfer uses - three rotated leaves, and two circles stacked on an oval become one tall - rounded box plus patches (the box fixes more pixels than either circle). - Two fixes were tried and dropped: proposing one shape over all of a - color's regions (the oval under the circles), and splitting regions at - narrow necks (into the two circles). Together they raised the total J by - 3% and cost 9 targets their lead over the human code: the greedy search - takes the big shapes early and then patches around them. +- **Search order.** Inside the greedy search, two fixes were tried and + dropped: proposing one shape over all of a color's regions (the oval + under the circles), and splitting regions at narrow necks (into the two + circles). Together they raised the total J by 3% and cost 9 targets their + lead over the human code: the greedy search takes the big shapes early + and then patches around them. The same splits now run in the re-search + (above), where the finished program decides. The lotus is still 66 + characters longer than the human code, which uses three rotated leaves. - **More primitives:** gradients on shapes, borders, text. - **Antialiasing:** a linear ramp only approximates how Chromium smooths a rotated edge, so striped flags still carry a few hundred wrong pixels. diff --git a/tests/test_codegen.py b/tests/test_codegen.py index 8d524d6..3bce7ab 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -1,6 +1,6 @@ import numpy as np -from cssynth.codegen import bgcolor_attr, color_css, emit, gradient_css, num, px, radius_css, reused_clip +from cssynth.codegen import bgcolor_attr, color_css, emit, gradient_css, num, px, radius_css, reused_clip, sides from cssynth.shapes import Gradient, Shape @@ -66,3 +66,11 @@ def test_mirrored_polygon_reuses_the_clip_path(): assert code.count("polygon(") == 1 and f"scale:{scale}" in code assert reused_clip(a, a.moved(5, 5)) == "" assert reused_clip(a, Shape.polygon([(10, 10), (60, 10), (41, 50), (20, 30)], 1)) is None + + +def test_sides_drop_what_css_repeats(): + assert sides(100, 100, 100, 100) == "100px" + assert sides(50, 100, 50, 100) == "50px 100px" + assert sides(50, 100, 70, 100) == "50px 100px 70px" + assert sides(50, 100, 50, 0) == "50px 100px 50px 0" + assert sides(0, 0, 0, 0) == "0" diff --git a/tests/test_search.py b/tests/test_search.py index 00c5289..e3b3d4e 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -3,15 +3,18 @@ from cssynth.search import ( _regions, + add_parts, area_sums, char_cost, + error, extract_palette, gain, + parts, reduce_polygon, soften, trace_outline, ) -from cssynth.shapes import Gradient, Shape, base_rgb, coverage +from cssynth.shapes import Gradient, Shape, base_rgb, coverage, render_labels def test_trace_outline_of_concave_region_is_exact(): @@ -116,3 +119,44 @@ def test_char_cost_of_a_shifted_copy_is_a_shadow_entry(): full = char_cost(a, [], pal) copy = char_cost(Shape(200, 40, 60, 60, 1), [a], pal) assert copy < full // 2 + + +def _disc(cx, cy, r): + yy, xx = np.mgrid[:300, :400] + 0.5 + return (xx - cx) ** 2 + (yy - cy) ** 2 <= r * r + + +def test_parts_split_touching_discs(): + region = _disc(200, 100, 50) | _disc(200, 200, 50) + ps = parts(region) + assert len(ps) == 2 + for p, disc in zip(sorted(ps, key=lambda m: np.nonzero(m)[0].mean()), (_disc(200, 100, 50), _disc(200, 200, 50))): + assert (p & ~disc).sum() == 0 and (disc & ~p).sum() < 0.03 * disc.sum() + + +def test_parts_leave_a_bar_without_a_core_to_the_search(): + bar = np.zeros((300, 400), dtype=bool) + bar[165:215, 150:300] = True + region = _disc(150, 165, 50) | _disc(230, 135, 50) | bar # a cloud + ps = parts(region) + assert len(ps) == 2 + left = (region & ~np.logical_or(*ps)).sum() + assert left > 0.5 * bar[:, 280:].sum() # the bar's far end is in no part + assert np.logical_or(*parts(region, whole=True)).sum() == region.sum() + + +def test_parts_of_one_shape_are_none(): + bar = np.zeros((300, 400), dtype=bool) + bar[100:150, 50:350] = True + assert parts(_disc(200, 150, 80)) == [] and parts(bar) == [] + + +def test_add_parts_draws_each_disc_on_its_own(): + # two circles stacked on a stadium: one shape over both circles fixes + # more pixels than either, so the greedy search takes it and patches + stadium = Shape(100, 100, 200, 100, 1, radii=(50,) * 4) + circles = [Shape(150, 50, 100, 100, 2, ell=True), Shape(150, 150, 100, 100, 2, ell=True)] + T = render_labels(0, [stadium] + circles) + shapes = add_parts(T, 0, [stadium], 2, parts(T == 2)) + assert len(shapes) == 3 and error(T, 0, shapes) < 100 + assert sorted((s.x, s.y, s.w, s.h) for s in shapes[1:]) == [(150, 50, 100, 100), (150, 150, 100, 100)] From 7c86019a309521121fbc2a21a6f9800206939335 Mon Sep 17 00:00:00 2001 From: Fedir <98510937+fMindstormer@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:50:39 -0500 Subject: [PATCH 02/11] Option to re-search the N best results, not only the best A result that starts a little worse sometimes re-searches to a much better program. compile_image(research=N) and --research N re-search the N best distinct results, in parallel when there are several processes, and keep the best; 1 stays the default. On cssynth's own scores, the 3 best lower total J by 2.3% on the development split and 2.5% on training, for 1.5-2x the compile CPU and little more wall time on 8 cores. The lotus drops from 484 characters to 291 (human: 418). bench/evaluate.py gains a top3 method. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 +++++--- bench/evaluate.py | 7 ++++- cssynth/__main__.py | 5 +++- cssynth/compiler.py | 61 +++++++++++++++++++++++++++++-------------- docs/method.md | 18 +++++++++++++ tests/test_compile.py | 14 +++++++++- 6 files changed, 89 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 7d93f2f..65b80ce 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ cssynth target.png # prints the code; length and details on stderr cssynth -v target.png # also logs the search cssynth --rho 2 target.png # golf harder (see "Objective") cssynth --fast target.png # skip the re-search: about 2x faster, longer code +cssynth --research 3 target.png # re-search the 3 best results: about 2% lower J, more CPU ``` From Python: @@ -245,10 +246,12 @@ flowchart LR the order they first help, so an early polygon can take a region that a shorter primitive would have drawn. The best result is taken apart and refilled: one polygon at a time (refilled without polygons of its color), - then every shape of one color (refilled as before, and without polygons). - Refills use the winning variant's mode. Each is refined and simplified, - and kept when J drops. `--fast` - skips this step. + then every shape of one color (refilled as before, and without polygons), + then every shape of a color whose region falls into parts, one shape per + part (two circles stacked on an oval). Refills use the winning variant's + mode. Each is refined and simplified, and kept when J drops. `--fast` + skips this step; `--research 3` runs it on the 3 best results, in + parallel, and keeps the best. ## Benchmark diff --git a/bench/evaluate.py b/bench/evaluate.py index 9243dc4..80cf29c 100644 --- a/bench/evaluate.py +++ b/bench/evaluate.py @@ -73,6 +73,11 @@ def _no_parts(path): return compiler.compile_image(path) +def _top3(path): + """The default compiler, re-searching the 3 best results.""" + return compiler.compile_image(path, research=3) + + def _fixes(path): """Everything but motifs.""" search.MOTIFS = set() @@ -102,7 +107,7 @@ def _learned(path): # name: path -> (code, info, model) METHODS = {"full": _full, "fast": _fast, "base": _base, "base-more": _base_more, - **{f"full-{n}": _full_more(n) for n in (16, 20)}, "fixes": _fixes, "no-parts": _no_parts, + **{f"full-{n}": _full_more(n) for n in (16, 20)}, "fixes": _fixes, "no-parts": _no_parts, "top3": _top3, "nest": _motifs(["nest"]), "nest-sym": _motifs(["nest", "symmetric"]), "all-motifs": _motifs(["nest", "symmetric", "seeded"]), "nest-allcolors": _motifs(["nest"], same_color=-1), "learned": _learned} diff --git a/cssynth/__main__.py b/cssynth/__main__.py index 3972b15..acde41f 100644 --- a/cssynth/__main__.py +++ b/cssynth/__main__.py @@ -16,8 +16,11 @@ def main(): help="processes for the search variants (default: all cores)") ap.add_argument("--fast", action="store_true", help="skip re-searching parts of the result (several times faster, a little longer code)") + ap.add_argument("--research", type=int, default=1, metavar="N", + help="re-search the N best results, not only the best (default %(default)s; 3 lowered the " + "benchmark's J by about 2%% for 1.5 to 2 times the CPU)") args = ap.parse_args() - code, info, _ = compile_image(args.image, rho=args.rho, research=not args.fast, jobs=args.jobs, + code, info, _ = compile_image(args.image, rho=args.rho, research=0 if args.fast else args.research, jobs=args.jobs, log=(lambda m: print(m, file=sys.stderr)) if args.v else None) print(code) print(f"{len(code)} chars, {info}", file=sys.stderr) diff --git a/cssynth/compiler.py b/cssynth/compiler.py index 28fc7b0..a002518 100644 --- a/cssynth/compiler.py +++ b/cssynth/compiler.py @@ -63,30 +63,51 @@ def _run_variant(arr, variant, rho, log=None): return runs -def compile_image(image, log=None, variants=VARIANTS, rho=RHO, research=True, jobs=1): - """image: a path or a PIL image. research: re-search parts of the best - result (see improve). jobs: processes to run the variants in (the - result is the same for any number).""" +def _research(arr, rho, run, log=None): + """Re-search one result (see improve); the result, improved or not.""" + J, code, info, (pal, bg, T, shapes) = run + min_gain, per_color, bbox_starts, _, outlines, by_length, motifs = info["variant"] + better = improve(lambda sh: _finish(arr, rho, pal, bg, sh)[0], pal, bg, T, shapes, rho, + min_gain, per_color, bbox_starts, outlines, by_length=by_length, motifs=motifs, log=log) + if better is shapes: + return run + J2, code, info2 = _finish(arr, rho, pal, bg, better) + return J2, code, {**info2, "variant": info["variant"], "researched": round(J - J2, 1)}, (pal, bg, T, better) + + +def compile_image(image, log=None, variants=VARIANTS, rho=RHO, research=1, jobs=1): + """image: a path or a PIL image. research: how many of the best distinct + results to re-search (see improve; 0 or False: none); the best one after + re-searching wins. A result that isn't the best can re-search to a better + one, so more costs CPU and can only lower J. jobs: processes to run the + variants and re-searches in (the result is the same for any number).""" img = (image if isinstance(image, Image.Image) else Image.open(image)).convert("RGB") if img.size != (400, 300): img = img.resize((400, 300), Image.BOX) arr = np.asarray(img) + ex = None if jobs > 1: from concurrent.futures import ProcessPoolExecutor # not in browsers (Pyodide) - with ProcessPoolExecutor(min(jobs, len(variants))) as ex: - per_variant = list(ex.map(_run_variant, *zip(*[(arr, v, rho) for v in variants]))) - else: - per_variant = [_run_variant(arr, v, rho, log) for v in variants] - runs = [run for rs in per_variant for run in rs] - J, code, info, (pal, bg, T, shapes) = min(runs, key=lambda r: r[0]) - if research: - min_gain, per_color, bbox_starts, _, outlines, by_length, motifs = info["variant"] - better = improve(lambda sh: _finish(arr, rho, pal, bg, sh)[0], pal, bg, T, shapes, rho, - min_gain, per_color, bbox_starts, outlines, by_length=by_length, motifs=motifs, - log=log) - if better is not shapes: - J2, code, info2 = _finish(arr, rho, pal, bg, better) - info = {**info2, "variant": info["variant"], "researched": round(J - J2, 1)} - shapes = better - return code, info, (pal, bg, T, shapes) + ex = ProcessPoolExecutor(min(jobs, len(variants))) + + def each(f, args): # in the pool, or here with the log + return list(ex.map(f, *zip(*args))) if ex and len(args) > 1 else [f(*a, log) for a in args] + + try: + runs = sorted((run for rs in each(_run_variant, [(arr, v, rho) for v in variants]) for run in rs), + key=lambda r: r[0]) + picked, seen = [], set() + for run in runs: + if len(picked) >= int(research): + break + if run[1] not in seen: + seen.add(run[1]) + picked.append(run) + if picked: + runs = each(_research, [(arr, rho, run) for run in picked]) + finally: + if ex: + ex.shutdown() + J, code, info, model = min(runs, key=lambda r: r[0]) + return code, info, model diff --git a/docs/method.md b/docs/method.md index 50c4653..e38ce5b 100644 --- a/docs/method.md +++ b/docs/method.md @@ -179,6 +179,24 @@ development targets (the oval, the cloud, 2024-03-30) become shorter than the human code, counting characters only; the match has yet to be checked in Chromium. +Re-searching only the best result can miss a better program: a result that +starts a little worse sometimes re-searches to a much better one. With +`research=3`, the three best distinct results are re-searched (in parallel +when there are several processes) and the best wins: + +| Split | Re-search the best | The 2 best | The 3 best | Re-search CPU (3 best) | +|---|---|---|---|---| +| Development (66) | 57,305 | 56,623 (−1.2%) | 55,994 (−2.3%) | 3.5× | +| Training (48) | 70,284 | 70,222 (−0.1%) | 68,534 (−2.5%) | 5.8× | + +The whole compile takes 1.5 to 2 times the CPU, but little more wall time +on 8 cores (the lotus: 16.9 s against 17.8 s). The lotus drops to 291 +characters (human: 418), from the second-best result. The winners come +from many different variants, so re-searching one fixed extra variant +wouldn't do the same. It isn't the default yet, since the published numbers +are the default's, measured in Chromium. (The training figures here include +the shorter `inset`, which the table above doesn't.) + ### Length-aware clean-up The search maximizes pixels fixed; the clean-up minimizes J. Its moves: diff --git a/tests/test_compile.py b/tests/test_compile.py index b596e04..f54ec04 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -51,12 +51,24 @@ def test_leaf_is_one_turned_shape(tmp_path): assert len(code) < 140 -def test_parallel_variants_give_the_same_code(tmp_path): +def disc_and_triangle(tmp_path): img = Image.new("RGB", (400, 300), "#62374e") d = ImageDraw.Draw(img) d.ellipse([100, 50, 199, 149], fill="#fdc57b") d.polygon([(250, 200), (350, 200), (300, 120)], fill="#fdc57b") path = tmp_path / "t.png" img.save(path) + return path + + +def test_parallel_variants_give_the_same_code(tmp_path): + path = disc_and_triangle(tmp_path) serial = compile_image(path, research=False)[0] assert compile_image(path, research=False, jobs=3)[0] == serial + + +def test_researching_more_results_never_scores_worse(tmp_path): + path = disc_and_triangle(tmp_path) + code, info, _ = compile_image(path, research=3, jobs=3) + assert info["J"] <= compile_image(path)[1]["J"] + assert compile_image(path, research=3)[0] == code From 9d502216f5e89bec43a0aa311a4ff63774786cb1 Mon Sep 17 00:00:00 2001 From: Fedir <98510937+fMindstormer@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:26:30 -0500 Subject: [PATCH 03/11] Benchmark: an input for how many results to re-search bench/run.py --research N, and the Benchmark workflow's research input (default 1), so --research 3 can be measured in Chromium too. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/benchmark.yml | 6 +++++- bench/run.py | 10 ++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 4f310ee..12cd154 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -4,6 +4,10 @@ name: Benchmark on: workflow_dispatch: + inputs: + research: + description: "Re-search the N best results (compile_image research=N)" + default: "1" jobs: bench: @@ -21,7 +25,7 @@ jobs: - run: npm ci working-directory: bench - run: python bench/fetch.py - - run: python bench/run.py + - run: python bench/run.py --research ${{ inputs.research }} - run: python bench/tradeoff.py - run: python bench/build_page.py - uses: actions/upload-artifact@v7 diff --git a/bench/run.py b/bench/run.py index c0a215d..495918a 100755 --- a/bench/run.py +++ b/bench/run.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Compile every target, render the output in Chromium, and score it. - python bench/run.py [--only id,id] [--jobs 8] + python bench/run.py [--only id,id] [--jobs 8] [--research 3] Writes bench/out/.html, bench/out/.png and bench/results.json. Match = share of pixels whose largest channel difference is at most 8/255, @@ -15,6 +15,7 @@ import subprocess import sys import time +from functools import partial from multiprocessing import Pool from pathlib import Path @@ -34,10 +35,10 @@ def match(a, b, tol=8): return float((np.abs(a.astype(int) - b.astype(int)).max(2) <= tol).mean() * 100) -def work(case): +def work(case, research=1): t = time.time() target = BENCH / "human" / f"{case['id']}.png" - code, info, (pal, bg, T, shapes) = compile_image(target) + code, info, (pal, bg, T, shapes) = compile_image(target, research=research) sim = render_rgb(bg, pal, shapes) img = np.asarray(Image.open(target).convert("RGB")) (OUT / f"{case['id']}.html").write_text(code) @@ -52,6 +53,7 @@ def main(): ap = argparse.ArgumentParser() ap.add_argument("--only") ap.add_argument("--jobs", type=int, default=os.cpu_count()) + ap.add_argument("--research", type=int, default=1, help="re-search the N best results (see compile_image)") args = ap.parse_args() cases = json.loads((BENCH / "cases.json").read_text()) if args.only: @@ -59,7 +61,7 @@ def main(): cases = [c for c in cases if c["id"] in keep] OUT.mkdir(exist_ok=True) with Pool(args.jobs) as p: - res = p.map(work, cases) + res = p.map(partial(work, research=args.research), cases) jobs = [{"html": r["code"], "out": str(OUT / f"{r['id']}.png")} for r in res] (BENCH / "jobs_out.json").write_text(json.dumps(jobs)) From 5c9983726287d884c450c452122462408cf9e63d Mon Sep 17 00:00:00 2001 From: Fedir <98510937+fMindstormer@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:36:00 -0500 Subject: [PATCH 04/11] Keep a shared padding when inset is shorter for one element; Chromium numbers Since inset drops repeated lengths, it can be the shortest encoding for one element and break a padding the elements would share (ks-04-upsndowns went from 207 to 214 characters). Code generation now also tries every element's shortest encoding without inset: about 55 characters shorter per benchmark run, never longer. The Benchmark workflow on this branch, against 0.6.0: total J 52,153 -> 48,985 (-6.1%), 23,329 -> 21,817 characters, 59 targets shorter than the human code instead of 56, all 66 still at >= 99% match; re-searching the 3 best results, 47,179 (-9.5%) and 21,283 characters. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++-- cssynth/codegen.py | 11 +++++++++-- docs/method.md | 23 ++++++++++++++++++----- tests/test_codegen.py | 7 +++++++ 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 65b80ce..51db12d 100644 --- a/README.md +++ b/README.md @@ -328,8 +328,9 @@ tests/ whose region falls into parts (two circles stacked on an oval, a cloud of discs) is redrawn with one shape per part, finished, and kept if the finished program scores better. On cssynth's own scores this lowered the - development total by 4.6% and made no target worse (docs/method.md); it - hasn't been measured in Chromium yet. Choices that don't show up as parts + development total by 4.6% and made no target worse; measured in + Chromium, 59 targets instead of 56 come out shorter than the human code + (docs/method.md). Choices that don't show up as parts of one color still go one step at a time, and a beam over partial J doesn't fix that: on 2024-02-27 the two-circle branch trails for two steps before it pulls ahead. diff --git a/cssynth/codegen.py b/cssynth/codegen.py index 5e3aa22..c007af2 100644 --- a/cssynth/codegen.py +++ b/cssynth/codegen.py @@ -626,8 +626,15 @@ def _elements(shapes, palette, edges, group_of, members): def _code(body_opts, elements) -> str: if not elements: return assemble(body_opts, []) - per_element = [min(size_encodings(e), key=lambda d: len(_style(d))) for e in elements] - return min((assemble(body_opts, els, tags, inline) for els in (per_element, elements) + encodings = [size_encodings(e) for e in elements] + # each element's shortest encoding; the same without inset, which can + # break a padding the elements would share; the plain properties + candidates = [] + for els in ([min(es, key=lambda d: len(_style(d))) for es in encodings], + [min(es[:-1], key=lambda d: len(_style(d))) for es in encodings], elements): + if els not in candidates: + candidates.append(els) + return min((assemble(body_opts, els, tags, inline) for els in candidates for inline in [_inline(els)] for tags in tag_options(els)), key=len) diff --git a/docs/method.md b/docs/method.md index e38ce5b..0cc7d78 100644 --- a/docs/method.md +++ b/docs/method.md @@ -176,8 +176,7 @@ The whole compile takes 5–8% more CPU. The two circles on the oval circle and its `box-shadow` copy. The cloud goes from 512 to 380, and the lotus from 551 to 484 (human: 418) with fewer wrong pixels. Three development targets (the oval, the cloud, 2024-03-30) become shorter than -the human code, counting characters only; the match has yet to be checked -in Chromium. +the human code. Re-searching only the best result can miss a better program: a result that starts a little worse sometimes re-searches to a much better one. With @@ -193,9 +192,23 @@ The whole compile takes 1.5 to 2 times the CPU, but little more wall time on 8 cores (the lotus: 16.9 s against 17.8 s). The lotus drops to 291 characters (human: 418), from the second-best result. The winners come from many different variants, so re-searching one fixed extra variant -wouldn't do the same. It isn't the default yet, since the published numbers -are the default's, measured in Chromium. (The training figures here include -the shorter `inset`, which the table above doesn't.) +wouldn't do the same. It isn't the default yet. (The training figures here +include the shorter `inset`, which the table above doesn't.) + +Measured in Chromium on Linux (the Benchmark workflow), against 0.6.0. J +here counts Chromium's wrong pixels; these runs predate a fix that keeps a +shared `padding` when `inset` would be shorter for one element, worth +about 55 more characters: + +| | Total J | Characters | ≥ 99% match | Shorter than human | +|---|---|---|---|---| +| 0.6.0 | 52,153 | 23,329 | 66 / 66 | 56 / 66 | +| Parts, plus the shorter `inset` | 48,985 (−6.1%) | 21,817 | 66 / 66 | 59 / 66 | +| The same, re-searching the 3 best | 47,179 (−9.5%) | 21,283 | 66 / 66 | 59 / 66 | + +The lowest match stays at 99.21%. Re-searching the 3 best puts the lotus +under the human length and takes the wig over it (227 characters to 285, +human: 264), for fewer wrong pixels. ### Length-aware clean-up diff --git a/tests/test_codegen.py b/tests/test_codegen.py index 3bce7ab..68f8ee8 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -74,3 +74,10 @@ def test_sides_drop_what_css_repeats(): assert sides(50, 100, 70, 100) == "50px 100px 70px" assert sides(50, 100, 50, 0) == "50px 100px 50px 0" assert sides(0, 0, 0, 0) == "0" + + +def test_a_shorter_inset_does_not_break_a_shared_padding(): + pal = np.array([(0, 0, 0), (255, 255, 255)]) + bowl, dome = Shape(50, 150, 100, 100, 1, radii=(0, 0, 50, 50)), Shape(150, 50, 100, 100, 1, radii=(50, 50, 0, 0)) + code, _ = emit(pal, 0, [bowl, dome]) + assert "inset" not in code and code.count("padding:50") == 1 From 683e07fef6ef0f18595e05748df2d10e92c67c26 Mon Sep 17 00:00:00 2001 From: Fedir <98510937+fMindstormer@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:47:10 -0500 Subject: [PATCH 05/11] Re-search the 3 best results by default compiler.RESEARCH = 3, used by compile_image, the CLI, bench/run.py, the ablation and the Benchmark workflow's default input. Measured in Chromium it lowered total J by 3.7% against re-searching only the best, for 1.5 to 2 times the CPU; with several processes the re-searches run in parallel. --research 1 (and evaluate.py's top1) keeps the old behavior. The browser demo passes its checkbox as on/off, so it still re-searches one result. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/benchmark.yml | 2 +- README.md | 18 ++++++++++-------- bench/ablation.py | 2 +- bench/evaluate.py | 8 ++++---- bench/run.py | 7 ++++--- cssynth/__main__.py | 10 +++++----- cssynth/compiler.py | 17 ++++++++++++----- docs/method.md | 22 +++++++++++----------- tests/test_compile.py | 2 +- 9 files changed, 49 insertions(+), 39 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 12cd154..c6a44b9 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -7,7 +7,7 @@ on: inputs: research: description: "Re-search the N best results (compile_image research=N)" - default: "1" + default: "3" jobs: bench: diff --git a/README.md b/README.md index 51db12d..b6cb854 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ cssynth target.png # prints the code; length and details on stderr cssynth -v target.png # also logs the search cssynth --rho 2 target.png # golf harder (see "Objective") cssynth --fast target.png # skip the re-search: about 2x faster, longer code -cssynth --research 3 target.png # re-search the 3 best results: about 2% lower J, more CPU +cssynth --research 1 target.png # re-search only the best result: less CPU, longer code ``` From Python: @@ -159,7 +159,7 @@ flowchart LR E --> F[simplify for length] F --> G[code generation] G --> H{lowest J
of 16 variants} - H --> I[re-search parts
of the best] + H --> I[re-search parts
of the 3 best] ``` 1. **Palette** (`search.extract_palette`): flat colors with at least 40 @@ -244,14 +244,16 @@ flowchart LR but not all: it can pick a cheap shape that leaves more for later. 8. **Re-search** (`improve.improve`): the greedy search commits to shapes in the order they first help, so an early polygon can take a region that a - shorter primitive would have drawn. The best result is taken apart and - refilled: one polygon at a time (refilled without polygons of its color), + shorter primitive would have drawn. Each of the 3 best results (distinct + programs, in parallel) is taken apart and refilled: one polygon at a + time (refilled without polygons of its color), then every shape of one color (refilled as before, and without polygons), then every shape of a color whose region falls into parts, one shape per part (two circles stacked on an oval). Refills use the winning variant's - mode. Each is refined and simplified, and kept when J drops. `--fast` - skips this step; `--research 3` runs it on the 3 best results, in - parallel, and keeps the best. + mode. Each is refined and simplified, and kept when J drops; the best + finished program wins, which isn't always the one that started best. + `--fast` skips this step; `--research 1` runs it on the best result + only. ## Benchmark @@ -306,7 +308,7 @@ ruff check . ``` cssynth/ compiler.py compile_image: run the variants, simplify, keep the lowest J - improve.py re-search: take the best result apart and refill it + improve.py re-search: take the best results apart and refill them search.py palette, base layer, greedy shape search, refine shapes.py the Shape model and a rasterizer that predicts Chromium's simplify.py length-aware clean-up moves diff --git a/bench/ablation.py b/bench/ablation.py index 25cdbcb..bdd5fb8 100644 --- a/bench/ablation.py +++ b/bench/ablation.py @@ -82,7 +82,7 @@ def work(args): name, case = args t = time.time() code, info, _ = compiler.compile_image(BENCH / "human" / f"{case['id']}.png", variants=variants(name), - research=name != "no-research") + research=0 if name == "no-research" else compiler.RESEARCH) return {"id": case["id"], "chars": len(code), "wrong_px": info["wrong_px"], "J": info["J"], "human_min_len": case["human_min_len"], "seconds": round(time.time() - t, 1)} diff --git a/bench/evaluate.py b/bench/evaluate.py index 80cf29c..2a387c8 100644 --- a/bench/evaluate.py +++ b/bench/evaluate.py @@ -73,9 +73,9 @@ def _no_parts(path): return compiler.compile_image(path) -def _top3(path): - """The default compiler, re-searching the 3 best results.""" - return compiler.compile_image(path, research=3) +def _top1(path): + """The default compiler, re-searching only the best result (v0.6.0).""" + return compiler.compile_image(path, research=1) def _fixes(path): @@ -107,7 +107,7 @@ def _learned(path): # name: path -> (code, info, model) METHODS = {"full": _full, "fast": _fast, "base": _base, "base-more": _base_more, - **{f"full-{n}": _full_more(n) for n in (16, 20)}, "fixes": _fixes, "no-parts": _no_parts, "top3": _top3, + **{f"full-{n}": _full_more(n) for n in (16, 20)}, "fixes": _fixes, "no-parts": _no_parts, "top1": _top1, "nest": _motifs(["nest"]), "nest-sym": _motifs(["nest", "symmetric"]), "all-motifs": _motifs(["nest", "symmetric", "seeded"]), "nest-allcolors": _motifs(["nest"], same_color=-1), "learned": _learned} diff --git a/bench/run.py b/bench/run.py index 495918a..55b14a8 100755 --- a/bench/run.py +++ b/bench/run.py @@ -24,7 +24,7 @@ ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) -from cssynth.compiler import compile_image # noqa: E402 +from cssynth.compiler import RESEARCH, compile_image # noqa: E402 from cssynth.shapes import bg_to_json, render_rgb # noqa: E402 BENCH = ROOT / "bench" @@ -35,7 +35,7 @@ def match(a, b, tol=8): return float((np.abs(a.astype(int) - b.astype(int)).max(2) <= tol).mean() * 100) -def work(case, research=1): +def work(case, research=RESEARCH): t = time.time() target = BENCH / "human" / f"{case['id']}.png" code, info, (pal, bg, T, shapes) = compile_image(target, research=research) @@ -53,7 +53,8 @@ def main(): ap = argparse.ArgumentParser() ap.add_argument("--only") ap.add_argument("--jobs", type=int, default=os.cpu_count()) - ap.add_argument("--research", type=int, default=1, help="re-search the N best results (see compile_image)") + ap.add_argument("--research", type=int, default=RESEARCH, + help="re-search the N best results (see compile_image; default %(default)s)") args = ap.parse_args() cases = json.loads((BENCH / "cases.json").read_text()) if args.only: diff --git a/cssynth/__main__.py b/cssynth/__main__.py index acde41f..9efd453 100644 --- a/cssynth/__main__.py +++ b/cssynth/__main__.py @@ -3,7 +3,7 @@ import os import sys -from .compiler import RHO, compile_image +from .compiler import RESEARCH, RHO, compile_image def main(): @@ -13,12 +13,12 @@ def main(): ap.add_argument("--rho", type=float, default=RHO, help="wrong pixels accepted per character saved (default %(default)s)") ap.add_argument("-j", "--jobs", type=int, default=os.cpu_count(), - help="processes for the search variants (default: all cores)") + help="processes for the search variants and re-searches (default: all cores)") ap.add_argument("--fast", action="store_true", help="skip re-searching parts of the result (several times faster, a little longer code)") - ap.add_argument("--research", type=int, default=1, metavar="N", - help="re-search the N best results, not only the best (default %(default)s; 3 lowered the " - "benchmark's J by about 2%% for 1.5 to 2 times the CPU)") + ap.add_argument("--research", type=int, default=RESEARCH, metavar="N", + help="re-search the N best results (default %(default)s; 1 takes less CPU, and its J was " + "about 4%% higher on the benchmark)") args = ap.parse_args() code, info, _ = compile_image(args.image, rho=args.rho, research=0 if args.fast else args.research, jobs=args.jobs, log=(lambda m: print(m, file=sys.stderr)) if args.v else None) diff --git a/cssynth/compiler.py b/cssynth/compiler.py index a002518..bc21df0 100644 --- a/cssynth/compiler.py +++ b/cssynth/compiler.py @@ -35,6 +35,12 @@ # harder (see bench/tradeoff.py). RHO = 0.5 +# How many of the best distinct results the re-search starts from (see +# compile_image). On the benchmark, 3 instead of 1 lowered J by 3.7% in +# Chromium for 1.5 to 2 times the CPU; with several processes they run in +# parallel, so the wall time grows less. +RESEARCH = 3 + def _finish(arr, rho, pal, bg, shapes): """(J, code, info) of a stack, scored against the target image.""" @@ -75,12 +81,13 @@ def _research(arr, rho, run, log=None): return J2, code, {**info2, "variant": info["variant"], "researched": round(J - J2, 1)}, (pal, bg, T, better) -def compile_image(image, log=None, variants=VARIANTS, rho=RHO, research=1, jobs=1): +def compile_image(image, log=None, variants=VARIANTS, rho=RHO, research=RESEARCH, jobs=1): """image: a path or a PIL image. research: how many of the best distinct - results to re-search (see improve; 0 or False: none); the best one after - re-searching wins. A result that isn't the best can re-search to a better - one, so more costs CPU and can only lower J. jobs: processes to run the - variants and re-searches in (the result is the same for any number).""" + results to re-search (see improve; 0 or False: none, True: 1); the best + one after re-searching wins. A result that isn't the best can re-search + to a better one, so more costs CPU and can only lower J. jobs: processes + to run the variants and re-searches in (the result is the same for any + number).""" img = (image if isinstance(image, Image.Image) else Image.open(image)).convert("RGB") if img.size != (400, 300): img = img.resize((400, 300), Image.BOX) diff --git a/docs/method.md b/docs/method.md index 0cc7d78..5d74dde 100644 --- a/docs/method.md +++ b/docs/method.md @@ -141,12 +141,12 @@ fewer shapes. The greedy search commits to shapes in the order they first help. An early polygon can take a region that a shorter primitive would have drawn, and -everything around it is then fitted to that choice. After the variants, the -best result goes through a small large-neighborhood search: remove one -polygon (and forbid polygons of its color), or every shape of one color -(with or without polygons), refill the gap with the greedy search from the -remaining stack (in the winning variant's mode), refine, simplify, and -keep the result if J drops. +everything around it is then fitted to that choice. After the variants, +each of the three best results goes through a small large-neighborhood +search: remove one polygon (and forbid polygons of its color), or every +shape of one color (with or without polygons), refill the gap with the +greedy search from the remaining stack (in that result's variant mode), +refine, simplify, and keep the result if J drops. A third neighborhood redraws a color whose region falls into parts. The region is thinned by its distance to the edge until it comes apart; each @@ -179,9 +179,9 @@ development targets (the oval, the cloud, 2024-03-30) become shorter than the human code. Re-searching only the best result can miss a better program: a result that -starts a little worse sometimes re-searches to a much better one. With -`research=3`, the three best distinct results are re-searched (in parallel -when there are several processes) and the best wins: +starts a little worse sometimes re-searches to a much better one. So the +three best distinct results are re-searched (in parallel when there are +several processes) and the best wins (`research=3`, the default): | Split | Re-search the best | The 2 best | The 3 best | Re-search CPU (3 best) | |---|---|---|---|---| @@ -192,8 +192,8 @@ The whole compile takes 1.5 to 2 times the CPU, but little more wall time on 8 cores (the lotus: 16.9 s against 17.8 s). The lotus drops to 291 characters (human: 418), from the second-best result. The winners come from many different variants, so re-searching one fixed extra variant -wouldn't do the same. It isn't the default yet. (The training figures here -include the shorter `inset`, which the table above doesn't.) +wouldn't do the same. (The training figures here include the shorter +`inset`, which the table above doesn't.) Measured in Chromium on Linux (the Benchmark workflow), against 0.6.0. J here counts Chromium's wrong pixels; these runs predate a fix that keeps a diff --git a/tests/test_compile.py b/tests/test_compile.py index f54ec04..1308a3f 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -70,5 +70,5 @@ def test_parallel_variants_give_the_same_code(tmp_path): def test_researching_more_results_never_scores_worse(tmp_path): path = disc_and_triangle(tmp_path) code, info, _ = compile_image(path, research=3, jobs=3) - assert info["J"] <= compile_image(path)[1]["J"] + assert info["J"] <= compile_image(path, research=1)[1]["J"] assert compile_image(path, research=3)[0] == code From bd0fcf6546543a94b6c77cd3e654f8892d5c4514 Mon Sep 17 00:00:00 2001 From: Fedir <98510937+fMindstormer@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:09:43 -0500 Subject: [PATCH 06/11] 0.7.0: version, ablation rows and a held-out workflow - pyproject: 0.7.0. - Ablation: no-parts and research-1 (re-search only the best) rows. - evaluate.py: v0.6 and v0.6-N, the search of 0.6.0 with N starts per step, for the held-out comparison at equal compute. - A Held-out workflow: generates one set, compiles it with each method one after another on one runner, renders in Chromium and prints the summary. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ablation.yml | 2 +- .github/workflows/heldout.yml | 46 ++++++++++++++++++++++++++++++++++ .gitignore | 1 + bench/ablation.py | 6 +++-- bench/build_page.py | 3 ++- bench/evaluate.py | 14 +++++++++-- pyproject.toml | 2 +- 7 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/heldout.yml diff --git a/.github/workflows/ablation.yml b/.github/workflows/ablation.yml index b98a34e..9b0bb67 100644 --- a/.github/workflows/ablation.yml +++ b/.github/workflows/ablation.yml @@ -33,7 +33,7 @@ jobs: strategy: fail-fast: false matrix: - config: [full, no-research, no-cleanup, no-exact-grouping, no-box-shadows, no-tag-rules, + config: [full, no-research, no-parts, research-1, no-cleanup, no-exact-grouping, no-box-shadows, no-tag-rules, no-clip-reuse, top-only, no-corner-split, no-outline-polygons, no-length-aware, no-gradient-base, no-soft-stops, no-motifs] steps: diff --git a/.github/workflows/heldout.yml b/.github/workflows/heldout.yml new file mode 100644 index 0000000..9874128 --- /dev/null +++ b/.github/workflows/heldout.yml @@ -0,0 +1,46 @@ +# Held-out evaluation: generate one held-out set of structural families +# (bench/families.py), compile it with each method one after another on the +# same runner (so CPU times compare), render in Chromium and print the +# summary (bench/evaluate.py). Run it by hand from the Actions tab, once per +# version: a set is spent once it has been run (see docs/method.md). +name: Held-out + +on: + workflow_dispatch: + inputs: + set: + description: "Held-out set, a key of HELDOUT_SEEDS in bench/families.py" + required: true + methods: + description: "Methods of bench/evaluate.py, space-separated" + required: true + default: "full" + +jobs: + evaluate: + runs-on: ubuntu-latest + timeout-minutes: 330 + env: + SET: ${{ inputs.set }} + METHODS: ${{ inputs.methods }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + - uses: actions/setup-node@v7 + with: + node-version: 22 + - run: pip install -e . + - run: npm ci + working-directory: bench + - run: python bench/families.py "$SET" + - run: for m in $METHODS; do python bench/evaluate.py --set "$SET" --method "$m"; done + - run: python bench/evaluate.py --set "$SET" --compare $METHODS | tee bench/eval/summary.txt + - uses: actions/upload-artifact@v7 + with: + name: heldout + path: | + bench/${{ inputs.set }}/cases.json + bench/${{ inputs.set }}/*.html + bench/eval/ diff --git a/.gitignore b/.gitignore index 798d80f..511dfc9 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ bench/train/ bench/learn/ bench/heldout2/*.png bench/heldout3/*.png +bench/heldout4/*.png # the demo site, assembled locally (demo/README.md) demo/site/ diff --git a/bench/ablation.py b/bench/ablation.py index bdd5fb8..c48e6cc 100644 --- a/bench/ablation.py +++ b/bench/ablation.py @@ -40,7 +40,9 @@ def _no_simplify(T, bg, pal, shapes, **kw): # name: (what is switched off, how) ABLATIONS = { "full": ("nothing", lambda: None), - "no-research": ("re-search of the best result", None), + "no-research": ("re-search of the best results", None), + "no-parts": ("redrawing a color in parts (re-search)", lambda: setattr(improve, "PARTS", False)), + "research-1": ("re-searching the 3 best results (the best only)", None), "no-cleanup": ("length-aware clean-up", lambda: (setattr(compiler, "simplify", _no_simplify), setattr(improve, "simplify", _no_simplify))), "no-exact-grouping": ("exact (CP-SAT) shadow grouping; greedy only", @@ -82,7 +84,7 @@ def work(args): name, case = args t = time.time() code, info, _ = compiler.compile_image(BENCH / "human" / f"{case['id']}.png", variants=variants(name), - research=0 if name == "no-research" else compiler.RESEARCH) + research={"no-research": 0, "research-1": 1}.get(name, compiler.RESEARCH)) return {"id": case["id"], "chars": len(code), "wrong_px": info["wrong_px"], "J": info["J"], "human_min_len": case["human_min_len"], "seconds": round(time.time() - t, 1)} diff --git a/bench/build_page.py b/bench/build_page.py index fb0b2ae..3d9f59c 100755 --- a/bench/build_page.py +++ b/bench/build_page.py @@ -19,7 +19,8 @@ def fmt(n): ABLATION_NAMES = { - "no-research": "Re-search of the best result", "no-cleanup": "Length-aware clean-up", + "no-research": "Re-search of the best results", "no-parts": "Redrawing a color in parts (re-search)", + "research-1": "Re-searching the 3 best results (the best only)", "no-cleanup": "Length-aware clean-up", "no-exact-grouping": "Exact shadow grouping (greedy kept)", "no-box-shadows": "Box-shadows", "no-tag-rules": "Per-tag rules (<i>, <u>, <s>)", "no-clip-reuse": "clip-path reuse for copies and mirrors", diff --git a/bench/evaluate.py b/bench/evaluate.py index 2a387c8..42b0c9f 100644 --- a/bench/evaluate.py +++ b/bench/evaluate.py @@ -68,16 +68,25 @@ def run(path): def _no_parts(path): - """The default compiler without re-searching colors in parts (v0.6.0).""" + """The default compiler without redrawing colors in parts.""" improve.PARTS = False return compiler.compile_image(path) def _top1(path): - """The default compiler, re-searching only the best result (v0.6.0).""" + """The default compiler, re-searching only the best result.""" return compiler.compile_image(path, research=1) +def _v06(starts=10): + """The search of v0.6.0: no parts, the best result re-searched only; + climbing more starts per step, for comparisons at equal compute.""" + def run(path): + improve.PARTS, search.TOP_STARTS = False, starts + return compiler.compile_image(path, research=1) + return run + + def _fixes(path): """Everything but motifs.""" search.MOTIFS = set() @@ -108,6 +117,7 @@ def _learned(path): # name: path -> (code, info, model) METHODS = {"full": _full, "fast": _fast, "base": _base, "base-more": _base_more, **{f"full-{n}": _full_more(n) for n in (16, 20)}, "fixes": _fixes, "no-parts": _no_parts, "top1": _top1, + "v0.6": _v06(), **{f"v0.6-{n}": _v06(n) for n in (16, 20, 24)}, "nest": _motifs(["nest"]), "nest-sym": _motifs(["nest", "symmetric"]), "all-motifs": _motifs(["nest", "symmetric", "seeded"]), "nest-allcolors": _motifs(["nest"], same_color=-1), "learned": _learned} diff --git a/pyproject.toml b/pyproject.toml index 9f13c5d..7ab30c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cssynth" -version = "0.6.0" +version = "0.7.0" description = "Compile flat images into short HTML/CSS, for CSSBattle-style code golf" readme = "docs/pypi.md" # the GitHub README uses relative links and a diagram PyPI can't show license = "MIT" From 23a51e76bdddd894c29023164dde5d5067bc1920 Mon Sep 17 00:00:00 2001 From: Fedir <98510937+fMindstormer@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:34:17 -0500 Subject: [PATCH 07/11] evaluate.py: v0.6-48, 0.6.0's search at 0.7.0's CPU On every third training target, 0.7.0 took 1,456 s of CPU against 503 s for 0.6.0's search; each extra start per step costs about 33 s there, so 48 starts spend at least as much (20 and 24 starts: 823 s and 964 s). More starts didn't lower J (22,640 at 10, 22,655 at 20, 22,673 at 24). Co-Authored-By: Claude Opus 5.5 --- bench/evaluate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bench/evaluate.py b/bench/evaluate.py index 42b0c9f..d70a29a 100644 --- a/bench/evaluate.py +++ b/bench/evaluate.py @@ -117,7 +117,7 @@ def _learned(path): # name: path -> (code, info, model) METHODS = {"full": _full, "fast": _fast, "base": _base, "base-more": _base_more, **{f"full-{n}": _full_more(n) for n in (16, 20)}, "fixes": _fixes, "no-parts": _no_parts, "top1": _top1, - "v0.6": _v06(), **{f"v0.6-{n}": _v06(n) for n in (16, 20, 24)}, + "v0.6": _v06(), **{f"v0.6-{n}": _v06(n) for n in (16, 20, 24, 48)}, "nest": _motifs(["nest"]), "nest-sym": _motifs(["nest", "symmetric"]), "all-motifs": _motifs(["nest", "symmetric", "seeded"]), "nest-allcolors": _motifs(["nest"], same_color=-1), "learned": _learned} From 4f781fd233191b244456454f131c7f116cc36f8c Mon Sep 17 00:00:00 2001 From: Fedir <98510937+fMindstormer@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:27:03 -0500 Subject: [PATCH 08/11] Linux results and ablation for 0.7.0 On the 66 development targets, rendered in Chromium: 60 shorter than the human code (was 56), 21,175 characters (was 23,329), all 66 at >= 99% match (lowest 99.21%), mean match 99.84%. Ablation: the re-search is now worth 14.4% of J, redrawing a color in parts 6.1%, re-searching the 3 best rather than the best 2.4%. The blog's "where it still loses" example (two circles on an oval) is now solved; it moves to a section on comparing whole programs, and the pin (2024-03-26, no gradients on shapes) takes its place. Captions follow the new programs for the sunburst and the rings. Co-Authored-By: Claude Opus 5.5 --- README.md | 47 +- bench/ablation.json | 132 +- bench/cssynth-results.html | 20 +- bench/out/ks-#159-portal.html | 2 +- bench/out/ks-#160-donut.html | 2 +- bench/out/ks-03-pushbutton.html | 2 +- bench/out/ks-05-acidrains.html | 2 +- bench/out/ks-05-acidrains.png | Bin 5766 -> 5773 bytes bench/out/ks-07-leafytrial.html | 2 +- bench/out/ks-07-leafytrial.png | Bin 4753 -> 4829 bytes bench/out/ks-09-tesseract.html | 2 +- bench/out/ks-134-wig.html | 2 +- bench/out/ks-134-wig.png | Bin 3206 -> 3281 bytes bench/out/ks-136-alieneye.html | 2 +- bench/out/ks-138-lotus.html | 2 +- bench/out/ks-138-lotus.png | Bin 5187 -> 6419 bytes bench/out/ks-139-lamb.html | 2 +- bench/out/ks-14-webmakerlogo.html | 2 +- bench/out/ks-14-webmakerlogo.png | Bin 5887 -> 5734 bytes bench/out/ks-145-spotlight.html | 2 +- bench/out/ks-145-spotlight.png | Bin 3769 -> 3768 bytes bench/out/ks-179-tanzania.html | 2 +- bench/out/ks-179-tanzania.png | Bin 10058 -> 10257 bytes bench/out/ks-181-iceland.html | 2 +- bench/out/ks-183-brazil.html | 2 +- bench/out/ks-184-india.html | 2 +- bench/out/ks-184-india.png | Bin 4805 -> 4882 bytes bench/out/ks-186-guernsey.html | 2 +- bench/out/ks-186-guernsey.png | Bin 2244 -> 2255 bytes bench/out/ks-202-sterringwheel.html | 2 +- bench/out/ks-202-sterringwheel.png | Bin 7118 -> 6894 bytes bench/out/ks-22-cloud.html | 2 +- bench/out/ks-22-cloud.png | Bin 3813 -> 4332 bytes bench/out/ks-37-tunnel.html | 2 +- bench/out/ks-46-mountains.html | 2 +- bench/out/ks-68-bell.html | 2 +- bench/out/ks-85-pythagoras.html | 2 +- bench/out/om-20240220.html | 2 +- bench/out/om-20240221.html | 2 +- bench/out/om-20240222.html | 2 +- bench/out/om-20240224.html | 2 +- bench/out/om-20240225.html | 2 +- bench/out/om-20240226.html | 2 +- bench/out/om-20240227.html | 2 +- bench/out/om-20240227.png | Bin 6832 -> 6916 bytes bench/out/om-20240228.html | 2 +- bench/out/om-20240301.html | 2 +- bench/out/om-20240301.png | Bin 3716 -> 6431 bytes bench/out/om-20240304.html | 2 +- bench/out/om-20240304.png | Bin 4285 -> 4353 bytes bench/out/om-20240309.html | 2 +- bench/out/om-20240312.html | 2 +- bench/out/om-20240312.png | Bin 2996 -> 3036 bytes bench/out/om-20240314.html | 2 +- bench/out/om-20240314.png | Bin 2508 -> 2519 bytes bench/out/om-20240315.html | 2 +- bench/out/om-20240317.html | 2 +- bench/out/om-20240318.html | 2 +- bench/out/om-20240320.html | 2 +- bench/out/om-20240321.html | 2 +- bench/out/om-20240322.html | 2 +- bench/out/om-20240323.html | 2 +- bench/out/om-20240324.html | 2 +- bench/out/om-20240325.html | 2 +- bench/out/om-20240325.png | Bin 3576 -> 3573 bytes bench/out/om-20240326.html | 2 +- bench/out/om-20240326.png | Bin 3012 -> 3362 bytes bench/out/om-20240327.html | 2 +- bench/out/om-20240328.html | 2 +- bench/out/om-20240329.html | 2 +- bench/out/om-20240330.html | 2 +- bench/out/om-20240330.png | Bin 1546 -> 1184 bytes bench/out/om-20240331.html | 2 +- bench/out/om-20240401.html | 2 +- bench/out/om-20240402.html | 2 +- bench/results.json | 2799 +++++++++++---------------- bench/tradeoff.json | 1588 +++++++-------- docs/blog/index.html | 24 +- docs/method.md | 53 +- docs/pypi.md | 2 +- 80 files changed, 2108 insertions(+), 2663 deletions(-) diff --git a/README.md b/README.md index b6cb854..962adc2 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,10 @@ Chromium and compared pixel by pixel: | | | |---|---| -| Shorter than the human solution (minified) | **56 of 66** | -| Total code length | **23,329** characters (human: 35,532) | +| Shorter than the human solution (minified) | **60 of 66** | +| Total code length | **21,175** characters (human: 35,532) | | Targets at ≥ 99% pixel match | **66 of 66** (lowest 99.21%) | -| Mean pixel match | 99.82% | +| Mean pixel match | 99.84% | The human solutions are readable code from two public repositories, minified but not golfed. Top CSSBattle players write far shorter code, so these @@ -61,25 +61,28 @@ rows don't add up. | Switched off | Total J | Characters | Shorter than human | |---|---|---|---| -| nothing (full compiler) | 60,441 | 23,329 | 56 / 66 | -| Insertion at any depth (shapes go on top) | 64,637 (+6.9%) | 24,887 (+1,558) | 54 / 66 | -| Polygons traced from outlines | 64,449 (+6.6%) | 25,791 (+2,462) | 52 / 66 | -| Re-search of the best result | 64,444 (+6.6%) | 24,652 (+1,323) | 55 / 66 | -| Gradient backgrounds | 63,265 (+4.7%) | 24,077 (+748) | 54 / 66 | -| Length-aware shape choice (by pixels only) | 62,921 (+4.1%) | 24,017 (+688) | 54 / 66 | -| Length-aware clean-up | 61,982 (+2.5%) | 24,914 (+1,585) | 52 / 66 | -| Splitting regions that touch at corners | 61,705 (+2.1%) | 24,005 (+676) | 55 / 66 | -| Nested-ring motifs | 61,309 (+1.4%) | 23,495 (+166) | 56 / 66 | -| Box-shadows | 61,258 (+1.4%) | 24,150 (+821) | 56 / 66 | -| Soft gradient stops | 61,106 (+1.1%) | 23,680 (+351) | 55 / 66 | -| Per-tag rules (``, ``, ``) | 60,807 (+0.6%) | 23,583 (+254) | 55 / 66 | -| `clip-path` reuse for copies and mirrors | 60,708 (+0.4%) | 23,544 (+215) | 55 / 66 | -| Exact (CP-SAT) shadow grouping, greedy kept | 60,554 (+0.2%) | 23,442 (+113) | 56 / 66 | - -Inserting shapes below existing ones, outline polygons and the re-search -matter most; gradient backgrounds and choosing shapes by length as well as -pixels come next. Exact shadow grouping and `clip-path` reuse are small on -average but decide a few targets. +| nothing (full compiler) | 55,925 | 21,175 | 60 / 66 | +| Re-search of the best results | 63,980 (+14.4%) | 24,086 (+2,911) | 56 / 66 | +| Redrawing a color in parts (re-search) | 59,350 (+6.1%) | 22,692 (+1,517) | 57 / 66 | +| Polygons traced from outlines | 59,032 (+5.6%) | 23,074 (+1,899) | 59 / 66 | +| Insertion at any depth (shapes go on top) | 58,545 (+4.7%) | 21,819 (+644) | 59 / 66 | +| Length-aware shape choice (by pixels only) | 57,824 (+3.4%) | 21,386 (+211) | 61 / 66 | +| Gradient backgrounds | 57,462 (+2.7%) | 21,514 (+339) | 59 / 66 | +| Re-searching the 3 best results (only the best) | 57,269 (+2.4%) | 21,691 (+516) | 60 / 66 | +| Nested-ring motifs | 56,758 (+1.5%) | 21,260 (+85) | 60 / 66 | +| Box-shadows | 56,674 (+1.3%) | 21,876 (+701) | 60 / 66 | +| Length-aware clean-up | 56,567 (+1.1%) | 21,779 (+604) | 61 / 66 | +| `clip-path` reuse for copies and mirrors | 56,184 (+0.5%) | 21,254 (+79) | 59 / 66 | +| Per-tag rules (``, ``, ``) | 56,171 (+0.4%) | 21,293 (+118) | 59 / 66 | +| Splitting regions that touch at corners | 55,990 (+0.1%) | 21,158 (−17) | 61 / 66 | +| Exact (CP-SAT) shadow grouping, greedy kept | 55,971 (+0.1%) | 21,221 (+46) | 60 / 66 | +| Soft gradient stops | 55,961 (+0.1%) | 21,167 (−8) | 60 / 66 | + +The re-search matters most, and its step that redraws a color in parts is +worth 6.1% on its own; outline polygons and inserting shapes below existing +ones come next. Three parts now move the total by 0.1% or less: splitting +regions at corners, soft gradient stops and exact shadow grouping. Rows that +show more targets shorter than human (61) do so at a higher total J. ### Beyond the development set diff --git a/bench/ablation.json b/bench/ablation.json index e707869..79f5f67 100644 --- a/bench/ablation.json +++ b/bench/ablation.json @@ -2,127 +2,145 @@ { "config": "full", "off": "nothing", - "chars": 23329, - "J": 60441, - "shorter": 56, - "cpu_min": 84.6, + "chars": 21175, + "J": 55925, + "shorter": 60, + "cpu_min": 251.1, "n": 66 }, { "config": "no-research", - "off": "re-search of the best result", - "chars": 24652, - "J": 64444, - "shorter": 55, - "cpu_min": 88.3, + "off": "re-search of the best results", + "chars": 24086, + "J": 63980, + "shorter": 56, + "cpu_min": 94.3, + "n": 66 + }, + { + "config": "no-parts", + "off": "redrawing a color in parts (re-search)", + "chars": 22692, + "J": 59350, + "shorter": 57, + "cpu_min": 218.1, + "n": 66 + }, + { + "config": "research-1", + "off": "re-searching the 3 best results (the best only)", + "chars": 21691, + "J": 57269, + "shorter": 60, + "cpu_min": 87.4, "n": 66 }, { "config": "no-cleanup", "off": "length-aware clean-up", - "chars": 24914, - "J": 61982, - "shorter": 52, - "cpu_min": 69.5, + "chars": 21779, + "J": 56567, + "shorter": 61, + "cpu_min": 136.5, "n": 66 }, { "config": "no-exact-grouping", "off": "exact (CP-SAT) shadow grouping; greedy only", - "chars": 23442, - "J": 60554, - "shorter": 56, - "cpu_min": 127.8, + "chars": 21221, + "J": 55971, + "shorter": 60, + "cpu_min": 114.6, "n": 66 }, { "config": "no-box-shadows", "off": "box-shadows", - "chars": 24150, - "J": 61258, - "shorter": 56, - "cpu_min": 58.8, + "chars": 21876, + "J": 56674, + "shorter": 60, + "cpu_min": 259.3, "n": 66 }, { "config": "no-tag-rules", "off": "per-tag rules (, , )", - "chars": 23583, - "J": 60807, - "shorter": 55, - "cpu_min": 122.0, + "chars": 21293, + "J": 56171, + "shorter": 59, + "cpu_min": 239.5, "n": 66 }, { "config": "no-clip-reuse", "off": "clip-path reuse for copies and mirrors", - "chars": 23544, - "J": 60708, - "shorter": 55, - "cpu_min": 130.6, + "chars": 21254, + "J": 56184, + "shorter": 59, + "cpu_min": 226.7, "n": 66 }, { "config": "top-only", "off": "insertion at any depth; new shapes go on top", - "chars": 24887, - "J": 64637, - "shorter": 54, - "cpu_min": 63.7, + "chars": 21819, + "J": 58545, + "shorter": 59, + "cpu_min": 138.0, "n": 66 }, { "config": "no-corner-split", "off": "splitting regions that touch at corners", - "chars": 24005, - "J": 61705, - "shorter": 55, - "cpu_min": 131.3, + "chars": 21158, + "J": 55990, + "shorter": 61, + "cpu_min": 168.6, "n": 66 }, { "config": "no-outline-polygons", "off": "polygons traced from outlines", - "chars": 25791, - "J": 64449, - "shorter": 52, - "cpu_min": 39.9, + "chars": 23074, + "J": 59032, + "shorter": 59, + "cpu_min": 134.4, "n": 66 }, { "config": "no-length-aware", "off": "length-aware shape choice; by pixels only", - "chars": 24017, - "J": 62921, - "shorter": 54, - "cpu_min": 88.8, + "chars": 21386, + "J": 57824, + "shorter": 61, + "cpu_min": 127.5, "n": 66 }, { "config": "no-gradient-base", "off": "gradient backgrounds", - "chars": 24077, - "J": 63265, - "shorter": 54, - "cpu_min": 95.5, + "chars": 21514, + "J": 57462, + "shorter": 59, + "cpu_min": 126.4, "n": 66 }, { "config": "no-soft-stops", "off": "soft gradient stops", - "chars": 23680, - "J": 61106, - "shorter": 55, - "cpu_min": 110.3, + "chars": 21167, + "J": 55961, + "shorter": 60, + "cpu_min": 227.8, "n": 66 }, { "config": "no-motifs", "off": "nested-ring motifs", - "chars": 23495, - "J": 61309, - "shorter": 56, - "cpu_min": 120.7, + "chars": 21260, + "J": 56758, + "shorter": 60, + "cpu_min": 155.5, "n": 66 } ] \ No newline at end of file diff --git a/bench/cssynth-results.html b/bench/cssynth-results.html index a56d4bf..a05a9d5 100644 --- a/bench/cssynth-results.html +++ b/bench/cssynth-results.html @@ -118,15 +118,15 @@

cssynth

66/66targets at 99% match or better (66 at 98%+)
-
327median characters on those
+
302median characters on those
480median for the human solutions, minified
-
56of those shorter than the human code
+
60of those shorter than the human code
64 → 66targets at 99%+ match, first version → now
-
10 → 22targets at a 100% match
-
34,963 → 23,329characters across all targets
-
−33%total code length since the first version
+
10 → 24targets at a 100% match
+
34,963 → 21,175characters across all targets
+
−39%total code length since the first version

Match counts pixels whose color is within 8/255 per channel of the target. The human solutions come from two public GitHub repos and are written to be readable, not golfed. Top CSSBattle players are far shorter, so these lengths are a baseline, not a leaderboard. All results, including the trade-off table, are rendered on Linux with the same Chromium build as the targets.

@@ -148,11 +148,11 @@

Outline polygons

Gradient backgrounds

-

For striped pictures the compiler fits a hard-stop linear-gradient at any angle: every pixel is projected onto the gradient line, and dynamic programming picks the band boundaries that minimize wrong pixels plus a cost per stop. Shapes are then layered on top as before. 9 targets use one.

+

For striped pictures the compiler fits a hard-stop linear-gradient at any angle: every pixel is projected onto the gradient line, and dynamic programming picks the band boundaries that minimize wrong pixels plus a cost per stop. Shapes are then layered on top as before. 5 targets use one.

Exactly optimal shadow grouping

-

Choosing which shapes become box-shadows of which, together with a valid paint order, is now solved exactly with a constraint solver: each shape gets a paint position, shadows share their anchor's, and every pair that must stay in order needs strictly increasing positions unless they share a group. Of 30 targets with any grouping, it beat the greedy grouping on 7 (42 characters) and proved optimality on 28.

+

Choosing which shapes become box-shadows of which, together with a valid paint order, is now solved exactly with a constraint solver: each shape gets a paint position, shadows share their anchor's, and every pair that must stay in order needs strictly increasing positions unless they share a group. Of 31 targets with any grouping, it beat the greedy grouping on 7 (51 characters) and proved optimality on 30.

Scoring by code length

@@ -189,13 +189,13 @@

All 66 targets

What each part buys

Each row recompiles all 66 targets with one technique switched off. Scores come from the compiler's own rasterizer rather than Chromium, and the parts overlap, so the rows don't add up. Inserting shapes below existing ones and re-searching the result matter most.

-
Switched offTotal JCharactersShorter than human
nothing (full compiler)60,44123,32956 / 66
Insertion at any depth64,637 (+6.9%)24,887 (+1,558)54 / 66
Polygons traced from outlines64,449 (+6.6%)25,791 (+2,462)52 / 66
Re-search of the best result64,444 (+6.6%)24,652 (+1,323)55 / 66
Gradient backgrounds63,265 (+4.7%)24,077 (+748)54 / 66
Length-aware shape choice62,921 (+4.1%)24,017 (+688)54 / 66
Length-aware clean-up61,982 (+2.5%)24,914 (+1,585)52 / 66
Splitting regions that touch at corners61,705 (+2.1%)24,005 (+676)55 / 66
Nested-ring motifs61,309 (+1.4%)23,495 (+166)56 / 66
Box-shadows61,258 (+1.4%)24,150 (+821)56 / 66
Soft gradient stops61,106 (+1.1%)23,680 (+351)55 / 66
Per-tag rules (<i>, <u>, <s>)60,807 (+0.6%)23,583 (+254)55 / 66
clip-path reuse for copies and mirrors60,708 (+0.4%)23,544 (+215)55 / 66
Exact shadow grouping (greedy kept)60,554 (+0.2%)23,442 (+113)56 / 66
+
Switched offTotal JCharactersShorter than human
nothing (full compiler)55,92521,17560 / 66
Re-search of the best results63,980 (+14.4%)24,086 (+2,911)56 / 66
Redrawing a color in parts (re-search)59,350 (+6.1%)22,692 (+1,517)57 / 66
Polygons traced from outlines59,032 (+5.6%)23,074 (+1,899)59 / 66
Insertion at any depth58,545 (+4.7%)21,819 (+644)59 / 66
Length-aware shape choice57,824 (+3.4%)21,386 (+211)61 / 66
Gradient backgrounds57,462 (+2.7%)21,514 (+339)59 / 66
Re-searching the 3 best results (the best only)57,269 (+2.4%)21,691 (+516)60 / 66
Nested-ring motifs56,758 (+1.5%)21,260 (+85)60 / 66
Box-shadows56,674 (+1.3%)21,876 (+701)60 / 66
Length-aware clean-up56,567 (+1.1%)21,779 (+604)61 / 66
clip-path reuse for copies and mirrors56,184 (+0.5%)21,254 (+79)59 / 66
Per-tag rules (<i>, <u>, <s>)56,171 (+0.4%)21,293 (+118)59 / 66
Splitting regions that touch at corners55,990 (+0.1%)21,158 (−17)61 / 66
Exact shadow grouping (greedy kept)55,971 (+0.1%)21,221 (+46)60 / 66
Soft gradient stops55,961 (+0.1%)21,167 (−8)60 / 66

Trading match for length

ρ is how many wrong pixels the compiler accepts to save one character. CSSBattle tells players to reach a 100% match before cutting characters, so the default is cautious. Raising ρ golfs harder. Each row below is the whole benchmark re-run at that setting, rendered in Chromium and scored.

-
SettingTargets at 99%+At 99.5%+Total charactersMedian match
default (ρ = 0.5)665923,32999.90%
ρ = 0.25665923,32999.90%
ρ = 1665922,562 (−3%)99.90%
ρ = 2665922,013 (−6%)99.89%
ρ = 4665921,452 (−8%)99.83%
+
SettingTargets at 99%+At 99.5%+Total charactersMedian match
default (ρ = 0.5)666121,17599.91%
ρ = 0.25666121,17599.91%
ρ = 1666120,893 (−1%)99.91%
ρ = 2666120,213 (−5%)99.89%
ρ = 4655919,560 (−8%)99.85%
@@ -224,7 +224,7 @@

Limits of this prototype