Skip to content

Latest commit

 

History

19 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 

Repository files navigation

Generative Art System

Turn everyday data (starting with Brave browsing history) into generated visual art, so novelty-seeking gets satisfied by creation instead of scrolling.

Status

Actively being built. (Previously queued behind other projects in the "replace doomscrolling" lineup per an earlier LLM Council pass — that ordering is no longer a hard gate; build order is whatever's useful.)

Architecture decision

  • No shared backend. No Neon Postgres for this project.
  • Even the standardized (timestamp, source, value, tags) shape used by other projects in this lineup is, at most, a natural input this system could render — it is not something this system needs to write to. This stays a standalone, read-only consumer of data, not another writer into shared infra.

Stack (decided)

Python + Pillow, rendering to static PNGs. Simple, scriptable, easy to run on a schedule and dump output to a local folder.

Data source (decided)

  • Source: Brave browsing/search history, auto-exported locally on a schedule (see scripts/).
  • Categorization: adaptive, domain-driven, not a fixed type list. categorize.py doesn’t hardcode categories like "stocks" or "video games" — it looks at whatever’s actually in your history, ranks domains by visit count, and turns the most-visited ones into their own categories automatically (named after the domain, e.g. youtube.com, claude.ai, github.com). Everything outside the top domains folds into other. This means the category set self-adjusts as browsing habits change — nothing to manually re-tune.
  • Category membership is stabilized with hysteresis, not recomputed fresh every run: a domain only gets promoted into its own category once it clears the strict top-TOP_N_DOMAINS cutoff, but only gets demoted once it falls past a wider buffer (TOP_N_DOMAINS + DEMOTE_BUFFER). State lives in data/category_membership.json (gitignored, falls back to a fresh selection if missing/corrupt). Without this, a domain hovering right at the cutoff could flicker in and out of its own category — and therefore its own color — from one run to the next, which would read as a glitch, not evolution.
  • The category mix feeding a render isn’t a single day’s snapshot — see "How it works" below for how recent days are blended together, and how that mix drives the piece.
  • The grid layout itself persists across renders (data/render_state.json, gitignored) — each render inherits roughly 70% of the previous render's shape positions/sizes/types, mutating the rest, so today's piece has actual lineage from yesterday's rather than being a full recompute each time. Falls back to a fresh layout if the state file is missing, corrupt, or the screen/icon size changed.

How it works

Pipeline, in order, all under scripts/:

  1. export_brave_history.ps1 — copies Brave's History SQLite DB out of its locked profile folder into data/brave_history/. Copying (rather than opening it directly) is what lets this run even while Brave is open.
  2. parse_brave_history.py — reads that copy, writes a flat per-URL summary to data/history.csv (url, title, visit_count, last_visit).
  3. categorize.py — reads the same SQLite copy's visits table (one row per visit event, not just per URL), ranks domains by visit count and picks the top ones as categories (see "Data source" above), and writes:
    • data/categorized_visits.csv — every visit tagged with a category
    • data/daily_category_mix.csv — visit counts per category per day
  4. generate_art.py — combines recent days into one snapshot with exponential recency weighting instead of using a single day's raw counts: each prior day's visits count 0.5 ** (days_ago / HALF_LIFE_DAYS) toward the mix (half-life 8 days, ~90-day lookback beyond which the weight is negligible). That means today's render is dominated by roughly the last few weeks, but shifts gradually day to day rather than jumping around on single-day noise or lurching once every 3 weeks. The weighted mix is renormalized back to a single-day scale (effective_visits) specifically so the coverage formula below still spans quiet-to-busy correctly instead of saturating once several weeks are blended together. Renders data/art/<date>.png (dated by the most recent completed day, the anchor of the decay) as a geometric mosaic sized to your actual screen resolution (detected via the Win32 API at render time, physical pixels not DPI-scaled logical ones, so the wallpaper is sharp). The canvas is divided into an 8x4 grid of sectors (SECTOR_COLS / SECTOR_ROWS); each sector, once active, is completely filled with small icon-sized shapes (matches your configured Windows shell icon size, read from the registry), drawn in randomly sized groups of those cells — mostly pairs and quads, sometimes bigger accent pieces — each one a random shape (triangle / quarter-circle fan / circle / semicircle / stripes / square) colored by a category chosen with probability proportional to that category's weighted share. Every group gets the same gutter inset regardless of size or color, so the piece reads as evenly spaced rather than a jumbled collage — the gutter is the negative space between pieces. Because sectors are strictly non-overlapping regions and each is packed independently with the same non-overlap-checked placement, symbols never overlap, across sectors or within one. Sectors activate starting from the top-left corner, expanding outward ring by ring by Manhattan distance (row + col) -- each ring is a diagonal band, so filling band by band gives the growing region a triangular shape (a diagonal edge sweeping down-right), not a square/rectangular one; how many sectors fill is not a fixed floor or a forced all-32, but a smooth, uncapped function of effective_visits (1 - e^(-effective_visits / COVERAGE_SCALE)), so the piece visibly grows or shrinks day to day. An inactive sector just stays background -- no fade, a clean edge, which reads better for this style than a gradient would. Background is black; the 5-color accent palette (ACCENT_COLORS in generate_art.py) is cream #F2E7DA, orange #BF7A4B, rust #B8653B, sage #629475, and dark green #3B6659 — brightened versions of an original cream-background palette (#F2E7DB / #C07A4A / #9B5B3A / #4F6B5A / #23312B), since the two darkest colors would have nearly vanished against true black. Each category hashes to one of the five accents, so the same domain gets a consistent-ish color across different days' pieces.
  5. set_wallpaper.ps1 — sets the newest PNG in data/art/ as the Windows desktop wallpaper (Fill style), so the piece is the delivery surface — nothing to open manually.
  6. prune_archive.py — every render is dated (data/art/<date>.png), so nothing gets overwritten day to day — but left alone that archive grows one PNG forever. This keeps every render from the last 90 days, and thins anything older down to one per ISO week (oldest render in each week survives), so a long-run timelapse stays possible without unbounded disk growth.
  7. run_export.ps1 — chains all six steps above. This is what a daily Windows Scheduled Task (GenerativeArtSystem-BraveHistoryExport) runs automatically at 9 AM, so the pipeline refreshes without manual intervention.

All of data/ (raw history, CSVs, rendered art) is gitignored — none of it is committed. Only the scripts that produce it are.

Favoriting a render

heart_widget.py is a separate, always-running small program (not part of the daily pipeline) — a borderless heart button pinned to the top-right corner of the screen. Click it to copy whatever render is currently the wallpaper into a dedicated folder, Pictures\GenerativeArtFavorites\ — separate from the automatic data/art/ archive, which keeps every render regardless. This folder only holds what you deliberately hearted.

Hearting is a once-a-day action, not a toggle: after you click it, the button hides itself for the rest of that day and only reappears once tomorrow's render becomes current (checked once a minute). If the process restarts mid-day after you've already hearted that render, it comes back up already hidden rather than re-showing.

It stays out of the way of other apps without ever being "always on top": it's an overrideredirect window, so it never shows up in the taskbar or Alt-Tab (no normal way to bring it forward), and instead of pinning itself above everything, it periodically re-lowers itself to the bottom of the window stack (once a second). Any app you open or click naturally ends up in front of it within about a second. This was chosen over reparenting into Explorer's WorkerW window (the technique interactive-wallpaper tools use for true desktop-layer attachment) after testing showed WorkerW discovery is unreliable across Windows sessions -- it depends on Explorer's undocumented internal window structure, which varies by version/session (confirmed failing in two different environments during development). Lowering the window is a plain, fully-supported stacking operation with no such dependency, so it behaves the same everywhere.

It uses a solid black background matching the wallpaper's own background color rather than true window transparency — Windows' -transparentcolor trick breaks on anti-aliased text/emoji (a single chroma key ends up matching some of a glyph's own blended edge pixels, producing a visible hatched artifact), so this sidesteps that entirely by just blending in. Works well in the top-right corner specifically, since the mosaic grows as a triangular wedge from the top-left corner toward the bottom-right (see "How it works" step 4) -- the top-right stays clear on all but the highest-coverage days. The single latest area reached is technically the bottom-right corner instead, for the same reason; worth moving the button there if coverage regularly gets high enough to reach the top-right first.

Launch it manually whenever you want it active:

pythonw scripts\heart_widget.py

To have it (and the resume watchdog below) start automatically every time you log into Windows, run scripts\setup_autostart.ps1 yourself. That's a separate, opt-in step rather than something the pipeline sets up on its own — logon-triggered auto-start is a more invasive, persistent capability than a scheduled daily task, worth a deliberate decision on your end.

That script uses the Windows Startup folder, not Task Scheduler: Register-ScheduledTask -AtLogOn returns Access is denied in this environment -- confirmed even run directly from an interactive session, not just from automation -- while plain daily-trigger tasks (the ones the main pipeline uses) register fine. The Startup folder is a different, equally standard Windows mechanism that isn't gated by whatever's blocking that specific Task Scheduler trigger type. Since a Startup-folder shortcut launches immediately at logon with no built-in delay option, the script generates a small VBScript launcher per program (data/*-launcher.vbs, gitignored -- they embed your machine's pythonw.exe path) that sleeps 45 seconds before actually starting it, so neither ever adds to the startup rush.

Resource use. Measured on a live instance before any of the changes below: ~0% CPU (below measurement precision over a 10s sample) and ~28MB working set, and ~170ms from process launch to the window being ready. That was already negligible in raw CPU% terms, but wake frequency matters for laptop idle power independent of that number, so the widget is built to minimize it regardless:

  • The button's own re-lower timer (see above) only runs while it's actually visible. Once hearted (or hidden on restart because it was already hearted), that timer stops entirely -- for most of the day, only the slow "did a new render appear" check keeps ticking.
  • Both timers are intentionally relaxed (2s for re-lowering, 5min for the render check) -- there's no reason either needs to be tighter for a button reacting to daily-cadence events.
  • The process sets its own priority to IDLE at startup, so it never competes with foreground apps for CPU scheduling regardless of how often it wakes. (Needed explicit HANDLE-width ctypes types for GetCurrentProcess/SetPriorityClass -- the default 32-bit marshaling truncates the pseudo-handle and fails silently with ERROR_INVALID_HANDLE on 64-bit Windows otherwise.)

This isn't a separate "low power mode" you opt into -- there's no real tradeoff to always running this way, so it's just how the widget behaves.

Recovering from sleep/wake glitches

After certain sleep/wake cycles, desktop compositing can end up in a stale state where the wallpaper stops actually being drawn -- another app's window (in practice, Rainmeter's own desktop-attached skin) ends up covering the screen instead, and the heart widget can end up hidden behind it too. Nothing in this pipeline is actually broken when this happens -- the wallpaper registry setting and the widget process are both still correct the whole time, they just aren't being rendered. Restarting explorer.exe (Task Manager → find "Windows Explorer" → Restart) fixes it by forcing everything to redraw; confirmed this manually once.

resume_watchdog.py automates that fix. It detects resume-from- sleep with a simple wall-clock heuristic rather than hooking raw Windows power-event messages (WM_POWERBROADCAST) -- it checks every 30 seconds, and if the elapsed time since the last check is much longer than that (>90s), this process was suspended for a while, since its own thread can't run at all during sleep. This works regardless of which underlying clock does or doesn't keep ticking during suspend, because it's the watchdog's own execution that stalls, not a hardware counter's behavior -- a more robust approach than the already-proven-unreliable-in-this-project WorkerW-style Windows internals. On detecting resume, it restarts explorer.exe, re-applies the wallpaper, and relaunches the heart widget if it isn't already running. Runs at IDLE priority like the heart widget, registered and started the same way (see above) -- both are set up by the same setup_autostart.ps1 script.

Design note: determinism vs. novelty

If the data-to-visual transform were purely deterministic, quiet/flat data days (barely any browsing, or one category dominating) would produce flat, boring art — undermining the whole point of satisfying novelty-seeking. generate_art.py handles this in a few ways: shape choice within every sector is seeded per-day random (not a fixed layout); which sectors within a ring get filled first, when the budget runs out mid-ring, is also seeded-random (so the boundary of a quiet day's piece is a jagged, organic edge rather than a clean rectangle); and even on the quietest day, at least the corner sector still renders rather than the canvas going fully blank. Real data still steers the color composition and how many sectors fill; the seeded randomness guarantees the arrangement is never the same twice.

Design note: evolving over time

Council-reviewed decision (see project history for the full session): rejected batching to one render every ~3 weeks, since that's a slower snapshot, not evolution — you'd notice it less, not more. Built instead, in order of the council's recommendation:

  1. The daily 9am render reads a decay-weighted recent-history window (see "How it works" step 4), so the ~3-week feel comes from a smooth, continuously-updating blend rather than an infrequent jump.
  2. The coverage formula runs on that window's renormalized average, not a raw multi-day sum, so it doesn't saturate near full-screen every render.
  3. Category-to-color identity is stabilized with hysteresis (see "Data source" above) so it can't flicker.
  4. The council's strongest point: decay-weighting alone is still a fresh recompute from raw history every time, not true persistence. A wallpaper only really "evolves" once today's render is a function of yesterday's render. The grid layout now persists across renders for exactly this reason (see "Data source" above) — today's piece has actual lineage from yesterday's, not just fresh data poured into a fresh recompute.
  5. Renders archive instead of overwriting, with weekly thinning past 90 days (see "How it works" step 6), so a long-run timelapse stays possible later without committing to building one now.

Next ideas (not built yet)

  • Something that actually uses the archive — a timelapse/GIF stitched from data/art/, or a quarterly grid tiling recent renders into one "generative memoir" piece.
  • Tune TOP_N_DOMAINS / DEMOTE_BUFFER (categorize.py) and SECTOR_COLS / SECTOR_ROWS / COVERAGE_SCALE / HALF_LIFE_DAYS / GUTTER_FRACTION / REUSE_PROBABILITY / MIN_SECTORS (generate_art.py) as real usage patterns become clearer.
  • Consider a secondary/fallback delivery surface beyond wallpaper (a local gallery folder?) — there's still no forcing function pulling this back into view otherwise.

About

Turns your Brave browsing history into generated visual art — self-adjusting, domain-driven categorization with Python + Pillow, no shared backend

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages