Skip to content

Instantly share code, notes, and snippets.

@Pyseph
Created January 31, 2025 12:32
Show Gist options
  • Save Pyseph/cf2365c8fc76da867961532cadb66101 to your computer and use it in GitHub Desktop.
Save Pyseph/cf2365c8fc76da867961532cadb66101 to your computer and use it in GitHub Desktop.
an optimized stroke generation module for images, utilizing Roblox's EditableImage, Felzenszwalb & Huttenloche's 1D exact distance transform algorithm, and bitswitching for squeezing out the most performance.
local module = {}
--MCR, Jan 2025
--Will return an editImage, the old size, and the new size if required
--eg: a 100x100 image with a 4 stroke will come back as 110x110 -> stroke * 2 + 2
--Also doesnt check for upper image size
-- NO LONGER SLOW! optimized by Pyseph :-)
function module:MakeStrokeImageFromAssetIdAsync(assetId : number, strokeSize : number) : EditableImage?
strokeSize = math.clamp(strokeSize, 1, 16)
local success, editableImage = pcall(function()
return game.AssetService:CreateEditableImageAsync(Content.fromUri(assetId))
end)
if (success == false) then
return nil
end
local needsPadding = module:CheckIfImageNeedsPadding(editableImage, strokeSize+1)
print("needs padding:", needsPadding)
if (needsPadding == false) then
local returnImage = module:CreateStrokeFromEditableImage(editableImage, strokeSize, Color3.new(1,1,1), 1, false)
return returnImage, editableImage.Size, returnImage.Size
else
local now = os.clock()
local paddedImage = module:PadEditableImage(editableImage, strokeSize + 1)
local paddingTime = os.clock() - now
now = os.clock()
local returnImage = module:CreateStrokeFromEditableImage(paddedImage, strokeSize, Color3.new(1,1,1), 1, false)
local strokeAddTime = os.clock() - now
print("padding time taken:", paddingTime)
print("stroke add time taken:", strokeAddTime)
return returnImage, editableImage.Size, returnImage.Size
end
end
local HUGE = 999999
function module:AddStrokeToImageFromAssetIdAsync(assetId : number, strokeSize : number, color:Color3, alpha:number) : EditableImage?
if (strokeSize < 1) then
strokeSize = 1
end
if (strokeSize > 16) then
strokeSize = 16 --Be reasonable sir!
end
local success, editableImage = pcall(function()
return game.AssetService:CreateEditableImageAsync(Content.fromUri(assetId))
end)
if (success == false) then
return nil
end
local needsPadding = module:CheckIfImageNeedsPadding(editableImage, strokeSize+1)
if (needsPadding == false) then
local returnImage = module:CreateStrokeFromEditableImage(editableImage, strokeSize, color, alpha, true)
return returnImage, editableImage.Size, returnImage.Size
else
local now = os.clock()
local paddedImage = module:PadEditableImage(editableImage, strokeSize + 1)
local paddingTime = os.clock() - now
now = os.clock()
local returnImage = module:CreateStrokeFromEditableImage(paddedImage, strokeSize, color, alpha, true)
local strokeAddTime = os.clock() - now
print("time taken:", paddingTime + strokeAddTime)
return returnImage, editableImage.Size, returnImage.Size
end
end
function module:CheckIfImageNeedsPadding(editableImage:EditableImage, numPixels : number) : boolean
if (numPixels < 0) then
numPixels = 0
end
local srcPixels = editableImage:ReadPixelsBuffer(Vector2.zero, editableImage.Size)
--Could just do a u32 read here, but whatever
local ix = 3
local width = editableImage.Size.X
local height = editableImage.Size.Y
local alphaBuffer = {}
for x = 0,editableImage.Size.X - 1 do
for y = 0, editableImage.Size.Y - 1 do
local a = buffer.readu8(srcPixels,ix)
ix+=4
alphaBuffer[x+y*width] = a
end
end
--Top rectangle
for x=0,width-1 do
for y =0,numPixels do
if alphaBuffer[x+y*width] > 0 then
return true
end
end
end
--Bottom Rectangle
for x = 0, width-1 do
for y = height - 1, height - numPixels - 1 do
if alphaBuffer[x+y*width] > 0 then
return true
end
end
end
--Left rectangle
for x = 0, numPixels do
for y = 0, height-1 do
if alphaBuffer[x+y*width] > 0 then
return true
end
end
end
--Right rectangle
for x = width - 1, width - numPixels - 1 do
for y = 0, height-1 do
if alphaBuffer[x+y*width] > 0 then
return true
end
end
end
return false
end
function module:PadEditableImage(editableImage: EditableImage, numPixels : number)
if (numPixels < 0) then
numPixels = 0
end
local options = { Size = Vector2.new(editableImage.Size.X + numPixels*2,editableImage.Size.Y + numPixels*2) }
local targetImage = game.AssetService:CreateEditableImage(options)
targetImage:DrawImage(Vector2.new(numPixels,numPixels), editableImage, Enum.ImageCombineType.AlphaBlend)
return targetImage
end
local function smoothStep(edge0 : number, edge1 : number, x : number)
local t = math.clamp((x - edge0) / (edge1 - edge0), 0, 1)
return t * t * (3 - 2 * t)
end
-- 1D exact distance transform from Felzenszwalb & Huttenloche
-- https://dsp.stackexchange.com/a/2180
local function distanceTransform1D(f, n)
local v = table.create(n)
local z = table.create(n + 1)
local g = table.create(n)
local k = 0
v[0] = 0
z[0] = -HUGE
z[1] = HUGE
for q = 1, n - 1 do
-- s = ((f[q] + q^2) - (f[v[k]] + v[k]^2)) / (2*q - 2*v[k])
local vk = v[k]
local s = ((f[q] + q^2) - (f[vk] + vk^2)) / (2*(q - vk))
while s <= z[k] do
k -= 1
vk = v[k]
s = ((f[q] + q^2) - (f[vk] + vk^2)) / (2*(q - vk))
end
k += 1
v[k] = q
z[k] = s
z[k + 1] = HUGE
end
local k2 = 0
for i = 0, n - 1 do
while z[k2 + 1] < i do
k2 += 1
end
local vk2 = v[k2]
g[i] = (i - vk2)^2 + f[vk2]
end
return g
end
function module:CreateStrokeFromEditableImage(
editableImage: EditableImage,
strokeSize: number,
strokeColor: Color3,
strokeAlpha: number,
useInteriorImage: boolean
)
local options = { Size = editableImage.Size }
local targetImage = game.AssetService:CreateEditableImage(options)
local width = editableImage.Size.X
local height = editableImage.Size.Y
-- Read src pixels (ARGB) in one pass
local srcPixels = editableImage:ReadPixelsBuffer(Vector2.zero, editableImage.Size)
local distBuffer = table.create(width * height, HUGE)
local colorBuffer = table.create(width * height)
local alphaBuffer = table.create(width * height, 0)
local strokeR = math.floor(strokeColor.R * 255 + 0.5)
local strokeG = math.floor(strokeColor.G * 255 + 0.5)
local strokeB = math.floor(strokeColor.B * 255 + 0.5)
-- populate alpha + initial dist
local readIndex = 0
for y = 0, height - 1 do
for x = 0, width - 1 do
local pixel32 = buffer.readu32(srcPixels, readIndex)
readIndex += 4
local A = bit32.rshift(bit32.band(pixel32, 0xFF000000), 24)
local R = bit32.rshift(bit32.band(pixel32, 0x00FF0000), 16)
local G = bit32.rshift(bit32.band(pixel32, 0x0000FF00), 8)
local B = bit32.band(pixel32, 0x000000FF)
local aFloat = A / 255
colorBuffer[x + y*width] = Color3.fromRGB(R, G, B)
alphaBuffer[x + y*width] = aFloat
if aFloat > 0 then
distBuffer[x + y*width] = 0
else
distBuffer[x + y*width] = HUGE
end
end
end
-- exact EDT: Felzenszwalb-Huttenlocher in two steps
-- (a) transform each row
local rowBuf = table.create(width)
for y = 0, height - 1 do
local base = y*width
-- gather row f[] from distBuffer
for x = 0, width - 1 do
rowBuf[x] = distBuffer[base + x]
end
local outRow = distanceTransform1D(rowBuf, width)
for x = 0, width - 1 do
distBuffer[base + x] = outRow[x]
end
end
-- (b) transform each column
local colBuf = table.create(height)
for x = 0, width - 1 do
-- gather column f[] from distBuffer
for y = 0, height - 1 do
colBuf[y] = distBuffer[x + y*width]
end
local outCol = distanceTransform1D(colBuf, height)
for y = 0, height - 1 do
distBuffer[x + y*width] = outCol[y]
end
end
-- distBuffer contains *squared* Euclidean distances to the nearest opaque pixel
-- write out final image
local dstPixels = buffer.create(width * height * 4)
local writeIndex = 0
for y = 0, height - 1 do
for x = 0, width - 1 do
local idx = x + y*width
local srcA = alphaBuffer[idx]
-- sqrt of squared distance
local distVal = math.sqrt(distBuffer[idx])
if srcA > 0 then
-- interior pixel
if not useInteriorImage then
-- solid stroke color inside
local outA = math.floor(strokeAlpha * 255 + 0.5)
local pixel32 =
bit32.lshift(outA, 24) +
bit32.lshift(strokeR, 16) +
bit32.lshift(strokeG, 8) +
strokeB
buffer.writeu32(dstPixels, writeIndex, pixel32)
writeIndex += 4
else
-- blend interior with stroke
local srcColor = colorBuffer[idx]
local outA = 255
local outR = (srcColor.R * srcA + strokeColor.R * (1 - srcA)) * 255
local outG = (srcColor.G * srcA + strokeColor.G * (1 - srcA)) * 255
local outB = (srcColor.B * srcA + strokeColor.B * (1 - srcA)) * 255
local pixel32 =
bit32.lshift(outA, 24) +
bit32.lshift(math.floor(outR + 0.5), 16) +
bit32.lshift(math.floor(outG + 0.5), 8) +
math.floor(outB + 0.5)
buffer.writeu32(dstPixels, writeIndex, pixel32)
writeIndex += 4
end
else
-- originally transparent
if distVal == HUGE then
-- not covered by stroke at all
buffer.writeu32(dstPixels, writeIndex, 0)
writeIndex += 4
else
-- within stroke range => compute alpha via smoothStep
local alphaDist = 1 - smoothStep(strokeSize - 0.5, strokeSize + 0.5, distVal)
local outA = math.floor(strokeAlpha * alphaDist * 255 + 0.5)
local pixel32 =
bit32.lshift(outA, 24) +
bit32.lshift(strokeR, 16) +
bit32.lshift(strokeG, 8) +
strokeB
buffer.writeu32(dstPixels, writeIndex, pixel32)
writeIndex += 4
end
end
end
end
targetImage:WritePixelsBuffer(Vector2.zero, targetImage.Size, dstPixels)
return targetImage
end
return module
@MrChickenRocket
Copy link

Love it! Here's a gold star ⭐

@Yuruzuu
Copy link

Yuruzuu commented Jan 31, 2025

Love it! Here's a gold star ⭐

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment