Created
January 31, 2025 12:32
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Love it! Here's a gold star ⭐