如何用 Canvas 為 AI 生成圖像排版文字,避免圖像模型拼字
Stop asking image models to spell: setting text over AI art with Canvas
作者分享以 Canvas 2D API 為 AI 生成封面圖疊加文字的做法,讓模型只生成圖像,文字由瀏覽器繪製並可編輯。實作涵蓋預載字體、逐行調整標題、按文字區域亮度切換字色,以及偵測並裁掉模型生成的邊框,毋須為改正拼字重新生成整張圖,也可改用較便宜的圖像模型。圖像由約 1024 像素放大至 3000 × 3000 不會增加細節,文字則以完整解析度繪製。
Image models have gotten very good at pictures and are still unreliable at words. Ask one for an album cover that says "Concrete Halo" and you will get "Concrete Halo" most of the time, "Concrete Hallo" some of the time, and occasionally a beautiful blackletter word that doesn't exist. For a cover, a poster or a book jacket, one wrong letter means the whole image is unusable.
We build design tools on top of image models, and for a while our album cover tool used a premium model specifically because it was better at lettering. It was also several times more expensive per image, and when it did misspell a word, the user paid again to regenerate everything just to fix a typo.
So we split the job. The model paints the artwork only (we tell it explicitly: no text, no letters), and the browser draws the artist name and title on top with the Canvas 2D API. The text is always spelled right, it can be edited without generating again, and the image itself can come from a much cheaper model.
Here is what that took in practice.
1. Load the fonts before you draw
Canvas draws with whatever font is available at the moment you call fillText. If the web font hasn't loaded yet, you silently get the fallback and a cover set in Times New Roman. We load each font explicitly with the FontFace API and cache the promise:
const loaded = new Map<string, Promise<void>>()
function loadFont(family: string, file: string, weight = '400'): Promise<void> {
const existing = loaded.get(family)
if (existing) return existing
const p = new FontFace(family, `url(${file}) format("woff2")`, {weight})
.load()
.then((face) => { document.fonts.add(face) })
.catch((err) => { loaded.delete(family); throw err })
loaded.set(family, p)
return p
}
Each of the 16 cover styles maps to a pair of fonts (one for the big line, one for the small one), and we only load the pair the current style needs before drawing.
2. Make the title fit, line by line
Titles vary wildly: "Halo" versus "The Night We Drove to the End of the Coast". We try sizes from the largest down, wrap by words at each size, and stop at the first size where every line fits the safe width and the number of lines stays under the limit:
function fitLines(measure, text, maxWidth, maxLines, max, min) {
const words = text.split(/\s+/).filter(Boolean)
for (let size = max; size >= min; size -= 4) {
const lines = []
let current = ''
let ok = true
for (const word of words) {
const next = current ? `${current} ${word}` : word
if (measure(next, size) <= maxWidth) current = next
else if (!current) { ok = false; break }
else { lines.push(current); current = word }
}
if (current) lines.push(current)
// Every line has to fit, including a long word pushed to the next line on its own.
if (ok && lines.length <= maxLines && lines.every((l) => measure(l, size) <= maxWidth)) {
return {lines, size}
}
}
return {lines: [words.join(' ')], size: min}
}
That last every check came from a bug report in German. A flyer headline wrapped fine, but the long compound word that moved to the second line on its own was wider than the flyer. Checking only the line that triggered the wrap isn't enough.
Letter spacing is done by hand, character by character, with measureText per glyph. ctx.letterSpacing exists now, but drawing glyph by glyph gives the same result in every browser, and wide tracking is part of several styles (vaporwave, electronic).
3. Read the image under the text
White text with a soft shadow works on most generated art, until the model paints a pale pastel sky exactly where the title goes. So before drawing, we sample the band of the image the text will sit on. We downscale it to a 48 × 48 canvas, read the pixels and average their relative luminance:
function bandLuminance(image, iw, ih, y0, y1, size = 3000) {
const s = 48
const c = document.createElement('canvas')
c.width = s
c.height = s
const ctx = c.getContext('2d', {willReadFrequently: true})
const scale = Math.max(s / iw, s / ih)
ctx.drawImage(image, (s - iw * scale) / 2, (s - ih * scale) / 2, iw * scale, ih * scale)
const r0 = Math.floor((y0 / size) * s)
const r1 = Math.ceil((y1 / size) * s)
const data = ctx.getImageData(0, r0, s, r1 - r0).data
let sum = 0
for (let i = 0; i < data.length; i += 4) {
sum += (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255
}
return sum / (data.length / 4)
}
Above a threshold (0.62 worked well in our tests) we switch to dark text and drop the shadow. Below it, we keep light text and paint a gradient "scrim" behind the band: a strip of semi-transparent black that fades out toward the middle of the image. The scrim strength is part of each style: heavy for metal, light for minimal.
4. The model sometimes paints a frame anyway
Even when the prompt asks for full-bleed artwork, a model will now and then return a picture inside a flat white or black border, like a print on a wall. Drawn at full size, that becomes a cover with a thick frame and the text sitting in empty margin.
We detect it on a small 160 × 160 copy. Starting from each edge, we count how many rows (or columns) are flat, meaning low color variance, all close to the color of the first one, and whether they end in a sharp cut where most of the line changes at once. A genuinely plain background, like a moon on a pink sky, doesn't end in a straight cut, so it is left alone. When a side shows a frame, we crop to the inside before drawing.
5. Upscaling honestly
Streaming platforms want covers at 3000 × 3000. The model returns about 1024 pixels, so the browser scales the image up when drawing it. One line made a visible difference:
ctx.imageSmoothingQuality = 'high' // the default is 'low'
ctx.drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)
It doesn't add detail that isn't there, so we tell users to check the file at 100% zoom before sending it to a distributor. The text, on the other hand, is drawn at full resolution, so the lettering is perfectly sharp even when the art is soft.
What we got, and what we gave up
- Exact text, every time, including names with accents and umlauts that models love to mangle.
- Editable text. Changing the title redraws the canvas in milliseconds instead of costing another generation.
- A cheaper model. The image no longer needs a model that is good at lettering, so the cover runs on our standard model instead of a premium one.
- Less typographic magic. Glow is a colored shadow, chrome is a vertical gradient, and neither is as rich as what a model can paint. For most covers that trade is worth it: a slightly plainer title spelled right beats a gorgeous one spelled wrong.
We use the same split, model for the picture and browser for the words, for book covers, posters, flyers, logos and cards. If you want to see the album version working, it's the AI album cover maker on CreArt, where the whole layout above runs client-side when you type your artist name.
If you've solved text on generated images differently, I'd like to hear how in the comments.
來源:DEV Community · dev.to