Author’s Note
This isn’t a confession—and it isn’t a eulogy for AI art.
It’s a line I’ve chosen to draw, and a prototype I’ve chosen to share—because refusing to use exploitative tools is only half a solution. The other half is creating an alternative: one built on consent, transparency, and the dignity of original work.
At the end of this article, you’ll find a small, local-only image generator. It’s not flashy. It’s more mathematic than artistic. It’s not trained on unlicensed portfolios. But it does what I need it to do—generate visual headers and thumbnails directly from my own text, using the colors I pick and the fonts on my machine.
The code will evolve. The principle will not.
Convenience should never come at the cost of consent.
—Dom
The rise of AI-generated imagery has sparked a cultural and ethical reckoning—one that too often targets individuals instead of systems. We now live in a moment where a few lines of text can summon a detailed, compelling visual with no invoice, no signature, and no traceable consent. For many creators, that shift feels less like progress and more like erosion.
This article isn’t a eulogy for human-made art, nor is it an ode to automation. It’s a principled line in the sand—and a working prototype that proves another path exists.
Instead of rejecting all AI tools or defending their worst implementations, this piece calls for a deeper reckoning: one that moves beyond outrage and into accountability. It explores what these generative systems actually do, why artists are right to be angry, and how ethical use might be possible—if we’re willing to build for it.
The tool shared at the end of this article, FWFT—For Writers, From Text—was developed in response to that challenge. It runs locally, uses no scraped data, and transforms written work into visual headers without stepping on the backs of uncredited artists. It’s not polished. But it is principled.
Ethics must be more than a stance. They must shape the systems we build—and the defaults we accept. If we’re serious about responsible innovation, then let’s stop waiting for the perfect solution and start creating imperfect ones that do less harm.
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.
This isn’t theoretical. Lawsuits from major content holders like Getty Images are already in progress. Class actions have been filed. Platforms like ArtStation faced widespread protests when artists discovered their uploads were silently swept into training sets. Even defenders of AI tools concede the foundations are ethically fragile at best.
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 art; 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 unlicensed foundations—and how do we use them, if at all, without compounding the harm?
Why Artists Are Angry
For many, creative work was the last domain considered safe from automation—an expression of human subjectivity that couldn’t be templated. So when a model began imitating not just style, but authorship, the reaction was not merely resistance. It was grief, reframed as fury.
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 began reproducing the texture of brushstrokes, the balance of color, the angle of light—with eerie precision and without attribution. 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; to someone for whom art is a lifestyle, it borders on identity theft.
Anger, given the background, is an unsurprising 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?
Anger aimed at unethical systems often misses its mark. Increasingly, it’s not just the platforms that built and profited from massive scraped datasets—OpenAI, Stability AI, Adobe—that bear the brunt. It’s independent creators: hobbyists, students, small studio devs, and writers who embed an AI-generated image as a placeholder or post banner.
This reaction marks a shift. We don’t target Photoshop users for “stealing” the dodge tool. We don’t attack fan artists for sketching Disney characters. We casually share unlicensed GIFs, mashups, and memes daily. Much of digital culture thrives on informal reuse.
But the allowance we extend to remix culture falters when applied to AI-generated imagery—because the scale is different, and so is the nature of the act. No one prompted a scraper to harvest a portfolio. No one gave consent. It was passive, industrial, and often invisible until the output began to resemble someone’s style a little too closely.
That violation of consent changes the emotional and ethical terrain. Art was one of the last places we believed human input couldn’t be automated. So its perceived desecration sparks deeper outrage. And in that outrage, we reach for purity.
But ethical purity is a myth. All of us operate in a lattice of compromise—streaming music made with extractive hardware, posting content on ad-supported platforms, working with tools assembled in factories where labor rights are fluid. Using AI-generated imagery isn’t an exception to that pattern. It’s just the latest, and most visible, tradeoff.
So no—most users aren’t the villains. They’re not printing bootleg canvases for resale. They’re not scraping new datasets. They’re just trying to make something, quickly, in a world where every tool has a shadow. If we care about fairness, then the conversation must start with honesty—about the world we’re in, the tradeoffs we’ve made, and how to do better from here.
Where the Anger Should Go
Anger, when well-aimed, can drive meaningful change. But misdirected, it becomes noise—or worse, friendly fire. The real harm here wasn’t caused by the student, the freelancer, or the writer trying to keep pace. It was built into the platforms and policies that made exploitation the default.
So, if your work has been used without your consent, or fed to a gestating model, 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 profiting off that foundation while offering artists no share, no say, no control.
To restore accountability, push lawmakers to close the widening gap between copyright law and technological reality—because today’s models are evolving far faster than the rules meant to govern them. Demand transparency from cloud providers. If we’re going to talk about carbon and water cost, let’s do it with the lights on and the data disclosed.
And within schools, studios, and employers? Start teaching not just how to use the tools, but how to think ethically about where the pixels come from—and who might be erased or displaced in the process.
The goal isn’t to halt progress. It’s to build systems that serve people—without erasing them.
Because you don’t fix a sinking ship by punching the person bailing water next to you. You fix it by sealing the breach and changing course
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— those running 15 queries per day—consume about 36 watt-hours and 1 ounce of water weekly. If that person already owns a smartphone, eats meat every meal, and flies occasionally, AI tools are far from their biggest environmental impact.
This doesn’t excuse unchecked emissions. But it does remind us: we should measure cost with perspective, not just assign blame based on the headlines.
What Ethical Use Should Look Like
Ethical use of image-generation tools isn’t just possible—it’s practical. But it requires intentional design. Consent, compensation, transparency, and education aren’t lofty ideals; they are foundational practices that can and should be baked into the architecture of every creative tool.
Consent: Ethical systems begin with permission. Models should never be trained on art that was scraped without knowledge or consent. Emerging platforms like Ascendant are proving it’s possible to invite artist contributions voluntarily and track influence, even tying royalties to derivative outputs. This is the baseline: if it’s not opt-in, it’s not ethical.
Compensation: When artist influence is used, payment should follow. Whether it’s direct licensing, royalty sharing, or community-funded attribution pools, any tool that profits from creative inputs should ensure those inputs are valued—not just technically, but economically. Procedural systems that avoid influence altogether should still build in structures for fair contribution when third-party assets are used.
Transparency: Outputs should be auditable. Whether through metadata sidecars, version hashes, or accessible training lineage, ethical tools must allow end-users—and critics—to trace what influenced a visual result, even if it’s through a similarity search. Obfuscation is not a feature. Opacity protects platforms, not artists and users.
Education: Tools should guide users to think clearly about authorship and attribution. That might mean nudging users away from prompts that mimic specific artists or embedding reminders when uploading, crediting, or saving outputs. These aren’t limitations—they’re opportunities to foster literacy, responsibility, and respect.
These practices are not theoretical. They’ve been implemented in small tools already—and they could be standard in large platforms if the will existed.
Ethics doesn’t need to slow innovation. But it does need to guide it.
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
Load any plain-text draft or raw notes. The script tokenizes the words, filters by length and optional stop-words, and extracts the top N most frequent terms. - Crunch the Numbers Locally
Word counts are transformed into a log-scaled array (for balanced visual distribution), all processed entirely in memory—your data never leaves your machine. - Pick a Visual Style
Choose from six visualization types using matplotlib (a standard Python data viz library): radial spider, radial wave, column, inverse column, line, or area. Each style renders simple geometric patterns—no AI-generated content involved. - Build the Background
Choose a transparent layer, a solid color, a two-stop gradient, or your own background image. Pillow, a Python image library, handles the rendering. - Overlay Your Credits
Add optional title and citation text. Position it in one of six locations. Size, and color are customizable (font color matches charts). - Export & Go
Save a high-resolution PNG or a six-second animated GIF (radial styles only). A compact JSON sidecar file logs metadata for transparency: palette, canvas size, and script hash.
One Python file, a few open libraries, zero scraping—and every pixel ties back to the words you wrote.
"""
FWFT – Sprint 2.4
Complete implementation of all basic features, including a working Reset method
and the necessary 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 <this file>.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