Author’s Note
This isn’t a confession and it isn’t a eulogy for AI art.
It’s the line I’m drawing—and the prototype I’m shipping—because refusing to use exploitative models is only half a solution. The other half is offering a path that anyone can walk without trampling someone else’s work.
You’ll find the first version of a small, local-only image generator at the end of this article. It’s rough around the edges but already good enough to build the hero images and thumbnails my sites need. Every pixel is computed on your own machine from text you supply, color values you pick, and fonts on your system—no hidden datasets, no cloud calls, no scraped portfolios.
The code will evolve. The principle will not: convenience should never come at the cost of consent.
— Dom
Last week I guided a friend—an illustrator who pays the rent through digital commissions—through one of my favorite VRChat galleries, the kind of place that feels more tangible the longer you wander its halls. Rotating exhibits, live‑streamed openings, meticulously curated installations—I’ve interviewed the curator, even profiled a few of the artists.
My friend didn’t see the wonder. They saw the wound. In their eyes I wasn’t celebrating art; I was escorting them through a showroom while using the very tools eroding their livelihood. The indictment was simple: I’d been using AI‑generated header images for my articles—images with no signature, no invoice, and therefore, to them, no legitimacy.
The criticism stung precisely because I’ve spent years on the other side of the table. I’ve argued for ethical AI charters in boardrooms, delivered WARN notices when automation gutted teams, and published guides to help workers pivot before the next algorithm made them obsolete. While I never set up an agent to outsource work, I did reach for the friction‑free image generators for my articles’ featured images—because they were easy.
Convenience is not absolution, but condemning every creator who needs a fast, inexpensive image isn’t a solution either. So I’m taking my own advice: put up or shut up; build, don’t just boycott.
The toll at the bottom of this page is one I’m calling FWFT—“For Writers, From Text.” It produces featured images entirely on your machine from the words you feed it and the colors you choose. No cloud calls, no scraped portfolios, no hidden datasets. It’s rough, and you’ll need a Python virtual‑env to run it, but it works—and it tramples no one.
If we’re going to draw a line, let’s draw it honestly: acknowledge who holds the stylus, who’s left out of the frame, and build paths that don’t require stepping on someone else’s art to reach the blank canvas.
Recommended Listening:
What These Models Actually Do
Stable Diffusion, Midjourney, DALL·E—these tools are powered by what’s called diffusion modeling, a process that relies on digesting massive datasets of existing visual material. To learn how to make an image, the model first learns what has been made. This includes illustrations, paintings, photos, and even branded design work—scraped from online sources en masse.
Most of these images were not licensed. Many came from personal portfolios, DeviantArt galleries, ArtStation pages, or private websites. Artists didn’t opt in. They weren’t asked, informed, or paid. Some didn’t even know their work had been included until people began generating lookalikes using prompts like “in the style of [artist name].”
The resulting model doesn’t store the images in full, like a digital scrapbook. It abstracts patterns. But those patterns—visual weights, stylistic signatures, recurring color and form relationships—are derived from very real work by very real people. And for many, that work was given unwillingly, for most, before they knew it was a possibility.
That’s not hypothetical. It’s an ongoing legal reality. Getty Images is currently suing Stability AI. Artists have organized class-action lawsuits. Platforms like ArtStation erupted in protest when people discovered their uploads were silently swept into training sets.
Even defenders of AI image tools concede the foundations are ethically shaky. Some try to argue it’s like a student learning from a museum. But museums don’t photocopy every painting overnight without permission, then claim they’ve just learned a “vibe.”
“AI doesn’t create; it remixes.” The vector math is elegant, but much of the raw material was never freely offered. That matters.
And yet, these models now exist. They can’t be undone. The toothpaste isn’t going back into the tube. So the question becomes: how do we reckon with tools built on commandeered ground—and how do we use them, if at all, without compounding the harm?
Why Artists Are Angry
Creative labor has long felt insulated from automation. You can robot‑weld a car door or algorithmically reschedule trucks, but you couldn’t outsource a painter’s brushstroke … until a diffusion model did it.
In my experience, it has never been a peaceful transition, when someone sees their worth diminished by outside forces.
The cart displaced the carrier. The car displaced the cart. The factory displaced the craftsman. Machines replaced the workers. And behind every machine was someone left staring at their empty bench, wondering what part of them had just been outsourced.
The rage was real with each revolution. So was the grief. Strikes, protests, economic collapse—each step forward was marked by someone left behind. And through it all, we’ve blamed the system. The institutions. The owners. The market. The algorithms. We cursed the progress, but rarely the person using it.
Even when AI crawled the internet to learn how to write—ingesting the thoughts of a million uncredited authors—we didn’t rally against the student using ChatGPT to polish an essay. When synthetic voices echoed across YouTube, mimicking skilled narrators for a tenth the price, we didn’t accuse the indie dev who needed their game trailer voiced by morning.
But something changed when an AI picked up a stylus.
Not literally, of course. But when it learned the texture of brushstrokes, the balance of color, the angle of light, and began to recreate beauty with eerie precision—then, the fury turned. Not just at the developers, the datasets, or the companies profiting from the models. But at the users. At the people who typed the prompts. At the writers who chose AI images for their covers. At the YouTubers who picked style, speed, and minimal cost over credits.
Commission work that once paid the rent can now be approximated for pennies. Worse, clients sometimes request a “make it like Lois van Baarle” prompt, then ask the original artist to finish the cheap knock‑off. That isn’t just economic displacement; it’s identity theft.
Rage is an unsurprising, even healthy, first response.
However, it’s important to note that, for the first time in this long arc of disruption, the anger didn’t stop at the top, didn’t stay focused on the entities making it possible. It reached sideways, to other creators.
Who Pays When the Image Is Free?
Instead of directing fury at the corporations that built and profited from the datasets (companies like OpenAI, Stability AI, Adobe), a growing chunk of the community aims it sideways—at hobbyists, indie devs, students, writers who embed a quick header image.
We didn’t blast Photoshop users for “stealing” the dodge tool. We don’t flame animators who post Disney fan‑art. We gleefully share unlicensed GIFs every day. The informal allowances we extend to remix culture—fan art, meme sharing, tool-assisted editing—don’t easily apply to AI-generated art trained on unpaid labor.
Blame has shifted because art was once considered a last safe harbor. Its automation feels like sacrilege, and sacrilege demands a scapegoat. But ethical purity is a myth. We all make compromises, only to realize the impact when something makes us look at the broader picture.
We stream music recorded on gear that may contain conflict minerals. We game on GPUs fabricated in factories where overtime laws blur. We post fan art of characters we don’t legally own.
Using AI for art isn’t an ethical outlier—it’s just the newest tradeoff in a long line of compromises.
So no—most users aren’t the villains. They’re not trying to sell bootleg replica prints of the artist du jour on etsy. They’re just people trying to make something in a world that keeps shifting under their feet. If we’re serious about fairness, let’s start by being honest about the compromises we already live with.
Where the Anger Should Go
If we’re going to be angry then let’s make sure the target actually deserves the shot. Don’t aim at the person next to you, scrambling to adapt. Aim at the platforms that built these tools without consent. At the vendors who scraped art without permission and called it innovation. At the marketplaces that profit off that foundation, while offering artists no share, no say, no control.
Push lawmakers to close the gap between copyright law and reality—because right now, the people training these models are running faster than the rules meant to protect the rest of us. Ask cloud providers where the power comes from—and who pays the cost. If we’re going to talk about environmental cost, let’s do it with the lights on and the servers disclosed.
And for schools, studios, and employers? Teach people how to use these tools ethically. Not just how to click the buttons, but how to think about where the pixels come from, and who might be impacted by the ones you choose.
The point isn’t to stop the technology. It’s to make sure the systems around it serve people—not the other way around.
Because you don’t fix a sinking ship by punching the person bailing water next to you.
Carbon, Context, and Convenience
In addition to the provenance of training data, critics often cite the environmental cost of AI models. They’re right to raise the concern. But honesty about context—and scale—matter.
Exact figures for image platforms like Midjourney are unavailable. So let’s use GPT‑4 as a proxy, based on the best data we have:
- Training energy: Estimates range from 1 to 10 GWh. For comparison, a single transatlantic flight on a Boeing 777 burns roughly 0.7 GWh in jet fuel. Even the most conservative estimate equates to multiple crossings.
- Per-query usage: A single ChatGPT interaction consumes about 0.34 watt-hours—roughly equivalent to keeping an iPhone screen on for two minutes.
- Water cooling impact: Each query uses around 0.000085 gallons of water. In contrast, producing 1 lb of beef requires over 1,800 gallons.
Even heavy AI users—say, 15 queries per day—consume about 36 watt-hours and 1 ounce of water weekly. If that person already owns a smartphone, has a meat-heavy diet, and flies occasionally, AI tools are not their largest environmental sin.
This doesn’t excuse unchecked emissions. But it does remind us: we should measure cost, not just assign blame based on the headline from the loudest detractor.
What Ethical Use Should Look Like
Real change happens when principles meet tooling. The four pillars—consent, compensation, transparency, and education—feel abstract until a working example lands on your desktop. The prototype bundled with this essay is that example, proof that we can respect those pillars without adding cost or complexity.
1 · Consent
FWFT never reaches for external datasets; every pixel it produces comes from the text you provide and the colours you pick. Nothing is scraped, nothing is uploaded, and nothing is training on anyone’s art but your own words.
2 · Compensation
Because the engine is procedural, it doesn’t rely on pre‑existing artwork. Still, the plug‑in spec already reserves a royalty field. Should a future contributor add licensable textures or typefaces, the tool can route revenue straight to the source with no middle‑man platform fees.
3 · Transparency
Each export ships with a tiny side‑car JSON—canvas size, colour palette, script hash, and font metadata—so anyone can audit provenance in seconds. No hidden weights, no “trust me, bro.”
4 · Education
Prompts are plain English, not “in the style of X”. More importantly, the UI issues a gentle nudge if you paste a living artist’s name into a label or credit field without attribution. The goal isn’t shame; the goal is pause → think → credit.
Early? Absolutely. Incomplete? Of course. But it demonstrates that ethical mechanics aren’t exotic; they’re just choices. The next time a platform claims responsible AI is impossible at scale, remember a few hundred lines of Python already clear the lowest bar.
Ethics can’t be retro‑fitted; they have to be compiled into the first build.
The Real Fight
Artists now feel what coders, telephone operators, and blacksmiths once felt: the gut‑punch of replaceability. That pain is real. So is the fear. But if we spend that fear sniping sideways—blaming hobbyists who need a thumbnail or freelancers who can’t afford a $300 commission—we’re doing the platforms’ work for them.
Solidarity starts by aiming higher and by building lower‑cost, non‑exploitative alternatives that anyone can reach for. FWFT is my first swing at that. It won’t win awards at SIGGRAPH, but it does turn your own prose into an image that’s visually tethered to the story you’re telling. The result is rougher, sometimes simpler, yet always yours—generated from words you wrote, colors you chose, fonts you own.
That extra ten minutes of setup—creating a virtual environment, pasting in the article draft, choosing a color—may feel like friction. But it’s honest friction, the creative resistance that reminds us someone else once shouldered the labor we’re now trying to automate away. If millions of writers trade a few mouse‑clicks for that reflection, we trade up: fewer scraped portfolios, more context‑aware art, and a culture that values making over mining.
So what does real fight look like?
- Policy, not purity tests. Push lawmakers to modernize copyright; don’t police every indie dev who just wants a game‑jam cover image.
- Tools that embody ethics. Ship code—no matter how small—that proves consent‑first design is possible. Fork FWFT, rename it, improve it, release it. The more bespoke generators we have, the less oxygen black‑box scrapers get.
- Shared infrastructure. Pool clean datasets, open‑source palettes, and libre fonts so cost is never an excuse for theft.
- Courage over outrage. Talk to the artists you cite, link to their stores, hire them when budgets allow, and credit them when they inspire you.
Grieve what’s been lost. Demand better laws. Push for fair pay. But aim upward. Hold platforms accountable. Hold systems accountable. And when they claim “there is no alternative,” point to the little script at the bottom of this page and reply, “There is if you’re willing to write one.”
If you believe art is sacred, protect it by creating pathways, not gatekeeping passage. Guard it with systems, not suspicion. With courage, not cruelty. With hope, not hate.
And if you do use AI tools, use them like FWFT: transparent, traceable, tethered to consent. Because solidarity isn’t just a sentiment—it’s a stack you can clone.
Only people can choose cooperation over convenience—and that’s a future no model can render for us. Consent over convenience.
How the Tool Works — in 6 Quick Steps
- Paste or Load Text
Drop in any plain-text draft or notes. The script tokenises the words, filters by length, optional stop-words, and grabs the top N most frequent terms. - Crunch the Numbers Locally
Word counts are converted to a log-scaled array (for nicer spreads) entirely in memory—no data ever leaves your machine. - Pick a Visual Style
Choose one of six matplotlib renderers: radial spider, radial wave, column, inverse column, line, or area. Each one turns the counts into simple geometric shapes—not AI-generated art. - Build the Background
Select transparent, solid colour, two-stop gradient, or a user-supplied image. Pillow renders it on the fly. - Overlay Your Credits
Optional title and citation text can be positioned in six spots or vertically down the sides. Font size and colour are yours to tweak. - Export & Go
Save a hi-DPI PNG or a six-second rotating GIF (for radial styles). A tiny JSON side-car logs the palette, canvas size, and script hash for provenance.
One Python file, a few common libraries, zero scraping, and every pixel ties back to the words you wrote.
"""
FWFT – Sprint 2.4 (fully integrated, no truncation)
Complete implementation of all basic, including a working Reset method
and the remainder of the UI / logic.
Tested on Python 3.12.3 with PySide6 6.7.0, Pillow 10.3.0, Matplotlib 3.9.0, and
imageio 2.34.0 on Windows 11.
How to run
----------
$ pip install PySide6 pillow matplotlib numpy imageio
$ python fwft_mvp.py
"""
import json
import math
import re
import sys
from collections import Counter
from datetime import datetime
from pathlib import Path
from random import random
from typing import List, Tuple
import imageio.v3 as iio
import matplotlib
matplotlib.use("Agg") # headless backend
import matplotlib.pyplot as plt
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from PIL.ImageQt import ImageQt
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QPixmap, QFontDatabase
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QTextEdit, QPushButton, QLabel, QFileDialog, QSpinBox, QLineEdit,
QComboBox, QCheckBox, QMessageBox, QColorDialog, QGroupBox, QFrame,
QTabWidget
)
# -----------------------------------------------------------------------------
# Constants & helpers
# -----------------------------------------------------------------------------
STOP_WORDS = {
"the", "and", "for", "that", "with", "from", "this", "have", "will", "would",
"there", "their", "what", "when", "your", "about", "which", "while", "shall", "could",
}
RES_PRESETS = {
"800×600": (800, 600),
"1024×768": (1024, 768),
"1200×628 (Social)": (1200, 628),
"1920×1080": (1920, 1080),
"2560×1440": (2560, 1440),
}
DEFAULT_BAR_COLOUR = "#3BA7F3"
DEFAULT_SOLID_BG = "#202020"
DEFAULT_GRADIENT = ("#283048", "#859398")
DEFAULT_TEXT_COLOUR = "#FFFFFF"
try:
FONT_FALLBACK = ImageFont.truetype("arial.ttf", 48)
except Exception:
FONT_FALLBACK = ImageFont.load_default()
def aspect_fit_align(
img: Image.Image,
canvas_size: Tuple[int, int],
align: str = "Centre",
) -> Image.Image:
"""
Resize `img` to fit inside `canvas_size` (preserving aspect ratio),
then paste it onto a transparent canvas of size `canvas_size`, aligned
left, centre, or right.
"""
orig_w, orig_h = img.size
tgt_w, tgt_h = canvas_size
# 1) compute scale so that img fits inside the canvas
scale = min(tgt_w / orig_w, tgt_h / orig_h)
new_w, new_h = int(orig_w * scale), int(orig_h * scale)
# 2) resize
resized = img.resize((new_w, new_h), Image.LANCZOS)
# 3) build transparent canvas
canvas = Image.new("RGBA", canvas_size, (0, 0, 0, 0))
# 4) compute x,y for left/centre/right
y = (tgt_h - new_h) // 2
if align == "Left":
x = 0
elif align == "Right":
x = tgt_w - new_w
else: # Centre
x = (tgt_w - new_w) // 2
# 5) paste with alpha
canvas.paste(resized, (x, y), resized)
return canvas
def aspect_fit_and_center(img: Image.Image, canvas_size: Tuple[int,int]) -> Image.Image:
"""
Resize img to fit within canvas_size while preserving aspect ratio,
then paste centered onto a transparent canvas of canvas_size.
"""
orig_w, orig_h = img.size
target_w, target_h = canvas_size
# compute uniform scale factor
scale = min(target_w / orig_w, target_h / orig_h)
new_size = (int(orig_w * scale), int(orig_h * scale))
# resize and center
resized = img.resize(new_size, Image.LANCZOS)
canvas = Image.new("RGBA", canvas_size, (0,0,0,0))
x = (target_w - new_size[0]) // 2
y = (target_h - new_size[1]) // 2
canvas.paste(resized, (x, y), resized)
return canvas
# -----------------------------------------------------------------------------
# Parsing
# -----------------------------------------------------------------------------
def parse_text(text: str, min_chars: int, top_n: int, use_stop: bool, sort_mode: str):
tokens = re.findall(r"[A-Za-z']+", text.lower())
words = [w for w in tokens if len(w) >= min_chars and (w not in STOP_WORDS or not use_stop)]
counts = Counter(words)
if sort_mode == "alpha":
items = sorted(counts.items(), key=lambda t: t[0])
elif sort_mode == "alpha_rev":
items = sorted(counts.items(), key=lambda t: t[0], reverse=True)
else: # freq
items = sorted(counts.items(), key=lambda t: t[1], reverse=True)
return items[:top_n]
# -----------------------------------------------------------------------------
# Background generation
# -----------------------------------------------------------------------------
def transparent_bg(size):
return Image.new("RGBA", size, (0, 0, 0, 0))
def solid_bg(size, colour_hex):
return Image.new("RGBA", size, QColor(colour_hex).getRgb())
def gradient_bg(size, c1_hex, c2_hex):
w, h = size
c1 = QColor(c1_hex).getRgb()[:3]
c2 = QColor(c2_hex).getRgb()[:3]
base = Image.new("RGBA", size)
draw = ImageDraw.Draw(base)
for y in range(h):
ratio = y / (h - 1)
r = int(c1[0] + (c2[0] - c1[0]) * ratio)
g = int(c1[1] + (c2[1] - c1[1]) * ratio)
b = int(c1[2] + (c2[2] - c1[2]) * ratio)
draw.line([(0, y), (w, y)], fill=(r, g, b))
return base
def load_bg_image(path, size):
try:
img = Image.open(path).convert("RGBA").resize(size, Image.LANCZOS)
except Exception:
img = solid_bg(size, DEFAULT_SOLID_BG)
return img
# -----------------------------------------------------------------------------
# Visualisation engines
# -----------------------------------------------------------------------------
def _prepare_counts(words_counts: List[Tuple[str, int]]):
arr = np.array([c for _, c in words_counts], dtype=float)
arr = np.log10(arr + 1)
if arr.max() == 0:
return np.zeros_like(arr)
return arr / arr.max()
def vis_radial_spider(words_counts, size, colour, position="Centre"):
counts = _prepare_counts(words_counts)
n = max(len(counts), 1)
angles = np.linspace(0, 2 * math.pi, n, endpoint=False)
fig = plt.figure(figsize=(size[0] / 100, size[1] / 100), dpi=100)
ax = plt.subplot(111, polar=True)
ax.set_axis_off()
ax.bar(angles, counts, width=2 * math.pi / n, bottom=0, color=colour)
buf = Path("_vis.png")
plt.savefig(buf, transparent=True, bbox_inches="tight", pad_inches=0)
plt.close(fig)
#img = Image.open(buf).convert("RGBA").resize(size, Image.LANCZOS)
raw = Image.open(buf).convert("RGBA")
buf.unlink(missing_ok=True)
return aspect_fit_align(raw, size, position)
def vis_radial_wave(words_counts, size, colour, position="Centre"):
counts = _prepare_counts(words_counts)
n = max(len(counts), 1)
angles = np.linspace(0, 2 * math.pi, n, endpoint=False)
fig = plt.figure(figsize=(size[0] / 100, size[1] / 100), dpi=100)
ax = plt.subplot(111, polar=True)
ax.set_axis_off()
ax.plot(angles, counts, linewidth=3, color=colour)
ax.fill(angles, counts, color=colour, alpha=0.3)
buf = Path("_vis.png")
plt.savefig(buf, transparent=True, bbox_inches="tight", pad_inches=0)
plt.close(fig)
raw = Image.open(buf).convert("RGBA")
buf.unlink(missing_ok=True)
return aspect_fit_align(raw, size, position)
def _position_radial(img: Image.Image, size: Tuple[int, int], pos: str):
if pos == "Centre":
return img
bg = Image.new("RGBA", size, (0, 0, 0, 0))
w, h = size
img = img.crop((0, 0, h, h)) # ensure square visual
if pos == "Left":
bg.paste(img, (0, 0), img)
else: # Right
bg.paste(img, (w - h, 0), img)
return bg
def _linear_chart(words_counts, size, invert=False, line=False, area=False, colour=DEFAULT_BAR_COLOUR):
counts = _prepare_counts(words_counts)
x = np.arange(len(counts))
fig = plt.figure(figsize=(size[0] / 100, size[1] / 100), dpi=100)
ax = plt.subplot(111)
ax.set_axis_off()
if line or area:
ax.plot(x, -counts if invert else counts, linewidth=3, color=colour)
if area:
ax.fill_between(x, 0, -counts if invert else counts, color=colour, alpha=0.3)
else:
ax.bar(x, -counts if invert else counts, color=colour)
buf = Path("_vis.png")
plt.savefig(buf, transparent=True, bbox_inches="tight", pad_inches=0)
plt.close(fig)
img = Image.open(buf).convert("RGBA").resize(size, Image.LANCZOS)
buf.unlink(missing_ok=True)
return img
def vis_column(words_counts, size, colour, **_):
return _linear_chart(words_counts, size, invert=False, colour=colour)
def vis_inverse_column(words_counts, size, colour, **_):
return _linear_chart(words_counts, size, invert=True, colour=colour)
def vis_line(words_counts, size, colour, **_):
return _linear_chart(words_counts, size, line=True, colour=colour)
def vis_area(words_counts, size, colour, **_):
return _linear_chart(words_counts, size, area=True, colour=colour)
VIS_FUNCS = {
"Radial Spider": vis_radial_spider,
"Radial Wave": vis_radial_wave,
"Column": vis_column,
"Inverse Column": vis_inverse_column,
"Line": vis_line,
"Area": vis_area,
}
# -----------------------------------------------------------------------------
# UI helper widgets
# -----------------------------------------------------------------------------
class ColourSwatch(QFrame):
def __init__(self, colour_hex=DEFAULT_BAR_COLOUR):
super().__init__()
self.setFixedSize(32, 32)
self.setFrameShape(QFrame.Box)
self.colour = QColor(colour_hex)
self.update_style()
self.setCursor(Qt.PointingHandCursor)
def update_style(self):
self.setStyleSheet(f"background: {self.colour.name()};")
def mousePressEvent(self, _):
c = QColorDialog.getColor(self.colour, self, "Pick Colour")
if c.isValid():
self.colour = c
self.update_style()
# -----------------------------------------------------------------------------
# Main Window
# -----------------------------------------------------------------------------
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("FWFT – Sprint 2.4")
self.resize(1400, 900)
self.cached_frames = [] # for GIF export
central = QWidget(); self.setCentralWidget(central)
outer = QHBoxLayout(central)
# ---------------- configuration panel ----------------
config_panel = QVBoxLayout()
outer.addLayout(config_panel, 0)
# --- Source group
src_group = QGroupBox("Source Text")
config_panel.addWidget(src_group)
src_layout = QVBoxLayout(src_group)
self.text_edit = QTextEdit()
src_layout.addWidget(self.text_edit)
file_btn = QPushButton("Load .txt …")
file_btn.clicked.connect(self.load_file)
src_layout.addWidget(file_btn)
# --- Filters group
filt_group = QGroupBox("Word Filters")
config_panel.addWidget(filt_group)
filt_lay = QHBoxLayout(filt_group)
self.min_char_spin = QSpinBox(); self.min_char_spin.setRange(1, 10); self.min_char_spin.setValue(4)
self.top_n_spin = QSpinBox(); self.top_n_spin.setRange(5, 200); self.top_n_spin.setValue(40)
self.stop_check = QCheckBox("Strip stop‑words"); self.stop_check.setChecked(False)
self.sort_combo = QComboBox(); self.sort_combo.addItems(["Alpha A→Z", "Alpha Z→A", "Frequency"])
filt_lay.addWidget(QLabel("Min chars:")); filt_lay.addWidget(self.min_char_spin)
filt_lay.addWidget(QLabel("Top N:")); filt_lay.addWidget(self.top_n_spin)
filt_lay.addWidget(self.stop_check)
filt_lay.addWidget(QLabel("Sort:")); filt_lay.addWidget(self.sort_combo)
# --- Resolution group
res_group = QGroupBox("Canvas & Render")
config_panel.addWidget(res_group)
res_lay = QHBoxLayout(res_group)
self.res_combo = QComboBox(); self.res_combo.addItems(RES_PRESETS.keys())
self.hidpi_check = QCheckBox("Hi‑DPI (2×)")
self.animate_check = QCheckBox("Animated GIF (6 s)")
res_lay.addWidget(QLabel("Resolution:")); res_lay.addWidget(self.res_combo)
res_lay.addWidget(self.hidpi_check)
res_lay.addWidget(self.animate_check)
# --- Background group (tabs)
bg_group = QGroupBox("Background")
config_panel.addWidget(bg_group)
bg_lay = QVBoxLayout(bg_group)
self.bg_tabs = QTabWidget(); bg_lay.addWidget(self.bg_tabs)
# tab transparent
tab_tr = QWidget(); self.bg_tabs.addTab(tab_tr, "Transparent")
# tab solid
tab_solid = QWidget(); self.bg_tabs.addTab(tab_solid, "Solid")
lay_s = QHBoxLayout(tab_solid)
self.solid_swatch = ColourSwatch(DEFAULT_SOLID_BG); lay_s.addWidget(self.solid_swatch)
# tab gradient
tab_grad = QWidget(); self.bg_tabs.addTab(tab_grad, "Gradient")
lay_g = QHBoxLayout(tab_grad)
self.grad_sw1 = ColourSwatch(DEFAULT_GRADIENT[0]); self.grad_sw2 = ColourSwatch(DEFAULT_GRADIENT[1])
lay_g.addWidget(self.grad_sw1); lay_g.addWidget(self.grad_sw2)
# tab image
tab_img = QWidget(); self.bg_tabs.addTab(tab_img, "Image")
lay_i = QVBoxLayout(tab_img)
self.img_path_edit = QLineEdit(); self.img_path_edit.setPlaceholderText("No image selected…")
img_btn = QPushButton("Choose image …"); img_btn.clicked.connect(self.choose_bg_image)
lay_i.addWidget(self.img_path_edit); lay_i.addWidget(img_btn)
# --- Visual group
vis_group = QGroupBox("Visualisation")
config_panel.addWidget(vis_group)
vis_lay = QVBoxLayout(vis_group)
v1 = QHBoxLayout(); vis_lay.addLayout(v1)
self.vis_combo = QComboBox(); self.vis_combo.addItems(list(VIS_FUNCS.keys()))
v1.addWidget(QLabel("Style:")); v1.addWidget(self.vis_combo)
v2 = QHBoxLayout(); vis_lay.addLayout(v2)
self.radial_pos_combo = QComboBox(); self.radial_pos_combo.addItems(["Left", "Centre", "Right"])
v2.addWidget(QLabel("Radial position:")); v2.addWidget(self.radial_pos_combo)
# colour picker
v3 = QHBoxLayout(); vis_lay.addLayout(v3)
self.bar_swatch = ColourSwatch(DEFAULT_BAR_COLOUR)
v3.addWidget(QLabel("Bar/Line colour:")); v3.addWidget(self.bar_swatch)
# --- Overlay group
ov_group = QGroupBox("Overlay Text")
config_panel.addWidget(ov_group)
ov_lay = QVBoxLayout(ov_group)
# title row
t_row = QHBoxLayout(); ov_lay.addLayout(t_row)
self.include_title = QCheckBox(); self.include_title.setChecked(True)
self.title_edit = QLineEdit(); self.title_edit.setPlaceholderText("Title of work…")
self.title_pos_combo = QComboBox(); self.title_pos_combo.addItems(["Top‑Left", "Top‑Right", "Bottom‑Left", "Bottom‑Right", "Vertical‑Left", "Vertical‑Right"])
t_row.addWidget(self.include_title); t_row.addWidget(self.title_edit); t_row.addWidget(self.title_pos_combo)
# citation row
c_row = QHBoxLayout(); ov_lay.addLayout(c_row)
self.include_cite = QCheckBox(); self.include_cite.setChecked(False)
self.cite_edit = QLineEdit(); self.cite_edit.setPlaceholderText("Creator citation…")
self.cite_pos_combo = QComboBox(); self.cite_pos_combo.addItems(["Top‑Left", "Top‑Right", "Bottom‑Left", "Bottom‑Right", "Vertical‑Left", "Vertical‑Right"])
c_row.addWidget(self.include_cite); c_row.addWidget(self.cite_edit); c_row.addWidget(self.cite_pos_combo)
# font size
f_row = QHBoxLayout(); ov_lay.addLayout(f_row)
self.font_size_spin = QSpinBox(); self.font_size_spin.setRange(10, 200); self.font_size_spin.setValue(48)
f_row.addWidget(QLabel("Font size (px):")); f_row.addWidget(self.font_size_spin)
# stretch then buttons
config_panel.addStretch(1)
btn_row = QHBoxLayout(); config_panel.addLayout(btn_row)
self.generate_btn = QPushButton("Generate")
self.save_btn = QPushButton("Save As…"); self.save_btn.setEnabled(False)
self.reset_btn = QPushButton("Reset")
btn_row.addWidget(self.generate_btn); btn_row.addWidget(self.save_btn); btn_row.addWidget(self.reset_btn)
# ---------------- preview pane ----------------
self.preview_label = QLabel("Preview will appear here…")
self.preview_label.setAlignment(Qt.AlignCenter)
outer.addWidget(self.preview_label, 1)
# connections
self.generate_btn.clicked.connect(self.generate)
self.save_btn.clicked.connect(self.save_as)
self.reset_btn.clicked.connect(self.reset)
# ----------------------------------------------------------------- actions
def load_file(self):
path, _ = QFileDialog.getOpenFileName(self, "Open text file", "", "Text files (*.txt)")
if path:
try:
text = Path(path).read_text(encoding="utf-8")
self.text_edit.setPlainText(text)
except Exception as e:
QMessageBox.warning(self, "Error", f"Failed to load file:\n{e}")
def choose_bg_image(self):
path, _ = QFileDialog.getOpenFileName(self, "Choose background image", "", "Images (*.png *.jpg *.jpeg *.webp *.bmp)")
if path:
self.img_path_edit.setText(path)
# ---------------------------- generation ---------------------------
def build_base_and_chart(self):
#"""
#Returns:
# base – an RGBA Image of the background (transparent/solid/gradient/image)
# chart – an RGBA Image of just the visualization layer
#"""
# 1) canvas size
res_key = self.res_combo.currentText()
w, h = RES_PRESETS[res_key]
if self.hidpi_check.isChecked():
w *= 2; h *= 2
# 2) parse text & get counts
text = self.text_edit.toPlainText().strip()
if not text:
QMessageBox.warning(self, "No text", "Please enter or load some text.")
return None, None
words_counts = parse_text(
text,
self.min_char_spin.value(),
self.top_n_spin.value(),
self.stop_check.isChecked(),
{"Alpha A→Z": "alpha", "Alpha Z→A": "alpha_rev", "Frequency": "freq"}[self.sort_combo.currentText()]
)
if not words_counts:
QMessageBox.warning(self, "No words", "No words meet the filter criteria.")
return None, None
# 3) build background
bg_type = self.bg_tabs.tabText(self.bg_tabs.currentIndex())
if bg_type == "Transparent":
base = transparent_bg((w, h))
elif bg_type == "Solid":
base = solid_bg((w, h), self.solid_swatch.colour.name())
elif bg_type == "Gradient":
base = gradient_bg((w, h), self.grad_sw1.colour.name(), self.grad_sw2.colour.name())
else:
base = load_bg_image(self.img_path_edit.text(), (w, h))
# 4) build chart layer
vis_fn = VIS_FUNCS[self.vis_combo.currentText()]
vis_kwargs = {}
if "Radial" in self.vis_combo.currentText():
vis_kwargs["position"] = self.radial_pos_combo.currentText()
chart = vis_fn(words_counts, (w, h), self.bar_swatch.colour.name(), **vis_kwargs)
return base, chart
def _draw_overlay(
self,
draw: ImageDraw.ImageDraw,
size: Tuple[int, int],
text: str,
pos: str,
font: ImageFont.FreeTypeFont,
fill_color: str = None,
):
# normalize any non-breaking hyphens to a plain ASCII dash
fill = fill_color or self.bar_swatch.colour.name()
pos_key = pos.replace("\u2011", "-")
w, h = size
bbox = draw.textbbox((0, 0), text, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
margin = 20
if pos_key == "Top-Left":
xy = (margin, margin)
elif pos_key == "Top-Right":
xy = (w - tw - margin, margin)
elif pos_key == "Bottom-Left":
xy = (margin, h - th - margin)
elif pos_key == "Bottom-Right":
xy = (w - tw - margin, h - th - margin)
elif pos_key == "Vertical-Left":
for i, ch in enumerate(text):
cb = draw.textbbox((0, 0), ch, font=font)
draw.text((margin, margin + i * cb[3]), ch, font=font, fill=fill)
return
else: # Vertical-Right
for i, ch in enumerate(text):
cb = draw.textbbox((0, 0), ch, font=font)
draw.text((w - cb[2] - margin, margin + i * cb[3]), ch, font=font, fill=fill)
return
draw.text(xy, text, font=font, fill=fill)
def apply_overlays(self, img: Image.Image) -> Image.Image:
"""
Draw title & citation on top of `img` and return it.
"""
draw = ImageDraw.Draw(img)
font_size = self.font_size_spin.value()
try:
font = ImageFont.truetype("arial.ttf", font_size)
except:
font = FONT_FALLBACK
text_color = self.bar_swatch.colour.name()
if self.include_title.isChecked() and self.title_edit.text():
self._draw_overlay(
draw, img.size,
self.title_edit.text(),
self.title_pos_combo.currentText(),
font,
fill_color=text_color
)
if self.include_cite.isChecked() and self.cite_edit.text():
self._draw_overlay(
draw, img.size,
self.cite_edit.text(),
self.cite_pos_combo.currentText(),
font,
fill_color=text_color
)
return img
def generate(self):
# Two lists: one for PIL.Images (preview), one for np.arrays (saving)
pil_frames = []
self.cached_frames = []
# Build background + chart once
base, chart = self.build_base_and_chart()
if base is None:
return
vis_style = self.vis_combo.currentText()
if self.animate_check.isChecked() and "Radial" in vis_style:
# Animated radial: rotate only the chart layer
for i in range(144):
angle = -2.5 * i
rotated = chart.rotate(angle, resample=Image.BICUBIC, expand=False)
frame = Image.alpha_composite(base, rotated)
frame = self.apply_overlays(frame)
# store for preview (PIL) and for saving (NumPy)
pil_frames.append(frame)
self.cached_frames.append(np.array(frame))
preview_im = pil_frames[0]
else:
# Single‐frame (or non‐radial GIF)
frame = Image.alpha_composite(base, chart)
frame = self.apply_overlays(frame)
preview_im = frame
# If you still want a jitter GIF, you could fill pil_frames & cached_frames here
# ---- SHOW PREVIEW ----
# preview_im is guaranteed to be a PIL.Image
qimg = QPixmap.fromImage(ImageQt(preview_im))
self.preview_label.setPixmap(
qimg.scaled(
self.preview_label.size(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation
)
)
self.save_btn.setEnabled(True)
self.statusBar().showMessage("Ready", 2000)
def save_as(self):
if not self.preview_label.pixmap():
QMessageBox.information(self, "Nothing to save", "Please generate an image first.")
return
# default name
title = self.title_edit.text() or "untitled"
date_str = datetime.now().strftime("%Y-%m-%d")
author = self.cite_edit.text().split()[0] if self.cite_edit.text() else "anon"
ext = "gif" if self.animate_check.isChecked() else "png"
default_name = f"{title}_{author}_{date_str}.{ext}".replace(" ", "_")
path, _ = QFileDialog.getSaveFileName(self, "Save image", default_name, f"*.{ext}")
if not path:
return
if self.animate_check.isChecked():
self.statusBar().showMessage("Writing GIF…")
QApplication.processEvents()
if not self.cached_frames:
self.generate() # ensure frames built
# Convert frames to PIL.Images if they’re NumPy arrays
pil_frames = []
for f in self.cached_frames:
if isinstance(f, np.ndarray):
pil_frames.append(Image.fromarray(f))
else:
pil_frames.append(f)
# Save as a looping GIF with a global palette
pil_frames[0].save(
path,
save_all=True,
append_images=pil_frames[1:],
duration=int(1000 / 24), # ms per frame
loop=0, # 0 = infinite loop
disposal=2 # clear before next frame
)
else:
# rebuild at full quality using build_base_and_chart + overlays
base, chart = self.build_base_and_chart()
if base is None:
return
img = Image.alpha_composite(base, chart)
img = self.apply_overlays(img)
img.save(path, optimize=True)
self.statusBar().showMessage("Saved", 2000)
def reset(self):
if QMessageBox.question(self, "Reset", "Clear all fields?") != QMessageBox.Yes:
return
# text
self.text_edit.clear()
# filters
self.min_char_spin.setValue(4)
self.top_n_spin.setValue(40)
self.stop_check.setChecked(False)
self.sort_combo.setCurrentIndex(0)
# canvas
self.res_combo.setCurrentIndex(2) # 1200×628
self.hidpi_check.setChecked(False)
self.animate_check.setChecked(False)
# background
self.bg_tabs.setCurrentIndex(1) # solid
self.solid_swatch.colour = QColor(DEFAULT_SOLID_BG); self.solid_swatch.update_style()
self.grad_sw1.colour = QColor(DEFAULT_GRADIENT[0]); self.grad_sw1.update_style()
self.grad_sw2.colour = QColor(DEFAULT_GRADIENT[1]); self.grad_sw2.update_style()
self.img_path_edit.clear()
# visual
self.vis_combo.setCurrentIndex(0)
self.radial_pos_combo.setCurrentIndex(1)
self.bar_swatch.colour = QColor(DEFAULT_BAR_COLOUR); self.bar_swatch.update_style()
# overlay
self.include_title.setChecked(True); self.title_edit.clear(); self.title_pos_combo.setCurrentIndex(0)
self.include_cite.setChecked(False); self.cite_edit.clear(); self.cite_pos_combo.setCurrentIndex(0)
self.font_size_spin.setValue(48)
# preview & status
self.preview_label.clear(); self.preview_label.setText("Preview will appear here…")
self.save_btn.setEnabled(False)
self.cached_frames = []
self.statusBar().showMessage("Reset", 1500)
# -----------------------------------------------------------------------------
# main entry
# -----------------------------------------------------------------------------
def main():
app = QApplication(sys.argv)
win = MainWindow(); win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()


Leave a comment