Calculating the enforcment of Avatar Default Clothing based on Delta-E CIE2000 BodyColor Similarity, just like Roblox API does it

Roblox recently officially announced the new RGB based Avatar Color Picker, and I started to realize issues with the Default Clothing Preview in the Universal App. This made me think of that Roblox most likely uses a formula to enforce Default Clothing on an Avatar or not, so I started

Hello, Avatar Editor Experience game developers, or people that just need the formula, or are curious!

I’ve been exploring so much more Avatar related things about Roblox.

 

Once, I made a guide about the DepthScale Formula:

 

I hope these guides, will encourage Avatar Editor Experience developers, to actually implement it in their game. Like Catalog Avatar Creator @ItsMuneeeb. I don’t think they have implemented it yet, unfortunately. :confused:

 

Today!, I will create a guide for the formula related to the enforcement of the Default Clothing on your Avatar.

Just like the other Formula Guide. I will only explain the step by step concept. See it as some sort of RFC Guide.

If you’re in doubt on the formulas, I don’t know what to tell you (I do mention though if a formula could be outdated, this one might has some components different). But you can battle-field test the formulas, such as the DepthScale one. If something is different, then it’s because it’s outdated and or Roblox changed something without saying what. (In this context, currently something has changed apparently, long ago)

What is this Formula useful for?

This formula is useful to validate, whether the BodyColors of your Roblox Avatar, will enforce Default Clothing or not. This is useful for Avatar Previews in Avatar Editors.

 

Pre-explanation before the Formula

Apparently, there’s a few more conditions, but these aren’t needed, they’re not related to us.

The Step by Step rough process

As you might know, same BodyColor, enforces Default Clothing, like so:

1. The check that determines everything.

image
 

The System compares your RightLeg BrickColor ID with your Torso BrickColor ID and the same for the LeftLeg BrickColor ID.

image

The RightLeg and the LeftLeg, are NOT allowed to be similar to the Torso’s body color. If any of it are, then your Lower Body colors are too similar to the Torso’s color and you’ll get enforced to wear Default Clothing.

As example, purely gonna name this function uhhh…
BodyColorsTooSimilar(RightLegColor, TorsoColor) and same for LeftLeg

2. The actual BodyColor similarity formula.

See below.

Formula for “BodyColor Too Similar”

Determines whether 2 body colors are similar enough to pass for “pure skin tone”. The Delta-E difference between two body colors is calculated using the CIE2000 algorithm, and the minimum threshold value is determined by a setting.

pure skin tone, was not part of the description, but doesn’t matter

Note, results could vary, based on what Roblox changes

This below is NOT code, only demonstration of the formula and its inputs.

BodyColorsTooSimilar(firstBrickColorId, secondBrickColorId)

 

As the quote says, a setting is defined. I will call it MinimumDeltaEColorDifference, which is 11.4. I hope they’ll say what they change it to, if they do change it. (Preferably through API)

Oh… it’s there already
image

 

  • You’ll need a Degrees to Radians Function.
    • degrees * 0.01745329251994329576923690768489, which is (pi/180)
  • RGB_to_Lab Function.
  • When I mention CIE2000Difference(color1, color2) I am talking about this Color difference - Wikipedia (Same page for Delta-E on Wikipedia btw.)
    • Yes… that’s the formula you’d need and the equavilient of the function.
    • Returns A number representing the CIE2000 Delta-E between color1 and color2

 

This is how the formula would be.

MinimumDeltaEColorDifference = 11.4

BodyColor_DeltaE(color1, color2) {
    ColorLab color1_lab = RGB_to_Lab(color1)
    ColorLab color2_lab = RGB_to_Lab(color2)

   return CIE2000Difference(color1_lab, color2_lab)
}


// Boolean is returned
BodyColorsTooSimilar(firstBrickColorId, secondBrickColorId) {
   Color3 firstColor = Color3FromBrickColorId(firstBrickColorId)
   Color3 secondColor = Color3FromBrickColorId(secondBrickColorId)

   return ( BodyColor_DeltaE(firstColor, secondColor) < MinimumDeltaEColorDifference )
}

Utility

To get the closest BrickColor ID through RGB, you can use:

BrickColor.new( Color3.fromRGB(r, g, b) )

Rough Code Example

Example Code
local MinimumDeltaEColorDifference = 11.4

--[[
	These are defined by
	International Commission on Illumination (CIE)
	https://en.wikipedia.org/wiki/Standard_illuminant
]]
local D65_WhiteReference = Vector3.new(
	95.047, -- X
	100.0, 	-- Y
	108.883 -- Z
)

--[[
	https://github.com/icrawler/luacolors
]]


-- Convert CIEXYZ to CIELAB, with PivotXyz
-- Numbers are from CIE 1931 standard
local function XYZ_To_Lab(clrX, clrY, clrZ)
	-- https://colour.readthedocs.io/en/v0.3.9/colour.constants.cie.html

	local function PivotXyz(n)
		local CIE_Epsilon = 0.008856451679035631
		local CIE_Kappa = 903.2962962962963

		if (n > CIE_Epsilon) then
			return math.pow(n, (1/3))
		end

		return (CIE_Kappa * n + 16) / 116
	end

	local x = PivotXyz(clrX / D65_WhiteReference.X)
	local y = PivotXyz(clrY / D65_WhiteReference.Y)
	local z = PivotXyz(clrZ / D65_WhiteReference.Z)

	local L = math.max(0.0, 116 * y - 16)
	local A = 500.0 * (x - y)
	local B = 200.0 * (y - z)

	return L, A, B
end

local function Color3_To_XYZ(clr: Color3)
	
	local function PivotRgb(n)
		-- Numbers apparently are from sRGB specification

		if (n > 0.04045) then
			return math.pow((n + 0.055) / 1.055, 2.4) * 100.0
		end

		return n / 12.92 * 100.0
	end
	
	
	-- Surprise surprise, we don't have to turn it into RGB
	-- It is already in Normalized RGB
	
	-- This would require the normalized RGB input
	local r = PivotRgb(clr.R)
	local g = PivotRgb(clr.G)
	local b = PivotRgb(clr.B)
	

	-- Contribution values or something defined by CIE
	local x = (r * 0.4124 + g * 0.3576 + b * 0.1805)
	local y = (r * 0.2126 + g * 0.7152 + b * 0.0722)
	local z = (r * 0.0193 + g * 0.1192 + b * 0.9505)

	return x, y, z
end


-- Convert Color3 to Lab
local function Color3_To_Lab(color3: Color3)
	return XYZ_To_Lab( Color3_To_XYZ(color3) )
end

local function DegreesToRadians(n)
	return n * (math.pi/180)
end
local DegToRad = DegreesToRadians


type ColorLab = {
	L: number;
	A: number;
	B: number;
}


function CIE2000Difference(clr1_Lab: ColorLab, clr2_Lab: ColorLab)
	local degrees360InRadians = DegreesToRadians(360.0)
	local degrees180InRadians = DegreesToRadians(180.0)

	local pow25To7 = math.pow(25, 7)

	local num = math.sqrt(clr1_Lab.A * clr1_Lab.A + clr1_Lab.B * clr1_Lab.B)
	local c2 = math.sqrt(clr2_Lab.A * clr2_Lab.A + clr2_Lab.B * clr2_Lab.B)
	local barC = (num + c2) / 2

	local g = 0.5 * (1.0 - math.sqrt(math.pow(barC, 7) / (math.pow(barC, 7) + pow25To7)) )
	local a1Prime = (1.0 + g) * clr1_Lab.A
	local a2Prime = (1.0 + g) * clr2_Lab.A
	local cPrime = math.sqrt(a1Prime * a1Prime + clr1_Lab.B * clr1_Lab.B)
	local cPrime2 = math.sqrt(a2Prime * a2Prime + clr2_Lab.B * clr2_Lab.B)

	local hPrime

	if (clr1_Lab.B == 0) and (a1Prime == 0) then
		hPrime = 0.0
	else
		hPrime = math.atan2(clr1_Lab.B, a1Prime)
		if (hPrime < 0.0) then
			hPrime += degrees360InRadians
		end
	end

	local hPrime2
	if (clr2_Lab == 0) and (a2Prime == 0) then
		hPrime2 = 0
	else
		hPrime2 = math.atan2(clr2_Lab.B, a2Prime)
		if (hPrime2 < 0.0) then
			hPrime2 += degrees360InRadians
		end
	end


	local num2 = clr2_Lab.L - clr1_Lab.L
	local deltaCPrime = cPrime2 - cPrime
	local cPrimeProduct = cPrime * cPrime2

	local deltaHPrime
	if (cPrimeProduct == 0.0) then
		deltaHPrime = 0.0
	else
		deltaHPrime = hPrime2 - hPrime

		if (deltaHPrime < -degrees180InRadians) then
			deltaHPrime += degrees360InRadians
		elseif (deltaHPrime > degrees180InRadians) then
			deltaHPrime -= degrees360InRadians
		end
	end
	deltaHPrime = 2.0 * math.sqrt(cPrimeProduct) * math.sin(deltaHPrime / 2.0)


	local barLPrime = (clr1_Lab.L + clr2_Lab.L) / 2.0
	local barCPrime = (cPrime + cPrime2) / 2.0
	local hPrimeSum = hPrime + hPrime2
	local barHPrime

	if ((cPrime * cPrime) == 0) then
		barHPrime = hPrimeSum

	elseif (math.abs(hPrime - hPrime2) <= degrees180InRadians) then
		barHPrime = hPrimeSum / 2.0

	elseif (hPrimeSum < degrees360InRadians) then
		barHPrime = (hPrimeSum + degrees360InRadians) / 2.0
	else
		barHPrime = (hPrimeSum - degrees360InRadians) / 2.0
	end

	local t = 1.0 - 0.17 * math.cos( barHPrime - DegToRad(30.0) )
		+ 0.24 * math.cos(2.0 * barHPrime)
		+ 0.32 * math.cos( 3.0 * barHPrime + DegToRad(6.0) )
	- 0.2 * math.cos( 4.0 * barHPrime - DegToRad(63.0) )

	local deltaTheta = DegToRad(30.0) * math.exp( -math.pow((barHPrime - DegToRad(275.0) )
		/ DegToRad(25.0), 2.0))



	local rC = 2.0 * math.sqrt(math.pow(barCPrime, 7.0)
		/ (math.pow(barCPrime, 7.0) + pow25To7))

	local sL = 1.0 + 0.015 * math.pow(barLPrime - 50.0, 2.0)
		/ math.sqrt(20.0 + math.pow(barLPrime - 50.0, 2.0))

	local sC = 1.0 + 0.045 * barCPrime
	local sH = 1.0 + 0.015 * barCPrime * t
	local rT = -math.sin(2.0 * deltaTheta) * rC


	return math.sqrt(
		math.pow(num2 / (1.0 * sL), 2.0)
			+ math.pow(deltaCPrime / (1.0 * sC), 2.0)
			+ math.pow(deltaHPrime / (1.0 * sH), 2.0)
			+ rT * (deltaCPrime / (1.0 * sC)) * (deltaHPrime / (1.0 * sH))
	)
end





-- Calculates the Delta-E distance between two colors given in RGB.
function BodyColor_DeltaE(color1: Color3, color2: Color3)
	local color1_Lab = {} :: ColorLab
	color1_Lab.L, color1_Lab.A, color1_Lab.B = Color3_To_Lab(color1)

	local color2_Lab = {} :: ColorLab
	color2_Lab.L, color2_Lab.A, color2_Lab.B = Color3_To_Lab(color2)

	return CIE2000Difference(color1_Lab, color2_Lab)
end




function BodyColorsTooSimilar(first_Color: Color3, second_Color)
	return BodyColor_DeltaE(first_Color, second_Color) < MinimumDeltaEColorDifference
end




-- We will compare two colors
local PseudoLowerBodyColor3 = Color3.fromRGB(50, 50, 50)
local PseudoTorsoColor3 = Color3.fromRGB(17, 17, 17)


print(BodyColorsTooSimilar(
	BrickColor.new(PseudoLowerBodyColor3).Color, BrickColor.new(PseudoTorsoColor3).Color
))

print(BodyColorsTooSimilar(
	PseudoLowerBodyColor3, PseudoTorsoColor3
))

 

That code should be good enough to demonstrate.

Don’t forget, you’d have to do this for RightLeg and LeftLeg with Torso

To understand what you actually need to input into the similarity check function, I made a test.

I wanted to figure out if it’s specifically Color3 and BrickColor.

It seems to be purely based on the RGB inputs, as the BrickColor type did not influence it at all. However, there seems to be something wrong with the formula, so either something changed or idk.

 

Don’t use the Thumbnail from the Avatar V1 Editor, as it is not accurate with the Default Clothing for some reason

local PseudoLowerBodyColor3 = Color3.fromHex("000000")
local PseudoTorsoColor3 = Color3.fromHex("050505")

As example, this one will still give Default Clothing, but not on the Thumbnail that you see in the Avatar V1 Editor. Simply because it’s old.

Meaning that in-game it will still look different. And that’s all that counts.

image

Same for this.

local PseudoLowerBodyColor3 = Color3.fromHex("a3fd8f")
local PseudoTorsoColor3 = Color3.fromHex("a2fd8e")

The formula from my example used wrong color conversions before, but I fixed them now!

Here is Roblox’s old avatar code, it’s pretty much the same thing.

When using Formulas compare them to this calculator, make sure all the Color Converting Formulas are correct!

https://colormine.org/delta-e-calculator/cie2000

Aslong you’re using the right CIE Formula, you should be fine!

You can eventually find some around GitHub, like fast/packages/utilities/fast-colors/src/color-converters.ts at master · microsoft/fast · GitHub

 

And there you go!

But eitherways, I hope this Guide was useful.

9 Likes

One other way of doing this is using the AvatarEditorService:CheckApplyDefaultClothing API. This is the exact same API we use in the app. AvatarEditorService:ConformToAvatarRules will also add default clothing if necessary.

If someone really wanted to have this code in Lua, here is the exact code we used to use in Lua before we added the CheckApplyDefaultClothing API to AvatarEditorService. This code is released as part of our reference avatar editor which is an old version of the app avatar editor.

3 Likes

I started to realize later that Roblox had a repository open somewhere. I don’t think I found anything for this though Calculating DepthScale of a Roblox Avatar like the Roblox backend API

https://avatar.roblox.com/v1/avatar-rules does return minimumDeltaEBodyColorDifference, which I noticed later. But it doesn’t return ScaleDepthWidthRatio. Something that is needed to Calculate the Depth Scale, just like how the other config value is needed for Delta-E.

 

Otherwise, there’s still some very interesting things to talk about. For instance, the old Website Avatar Editor’s Thumbnail Preview, seems to undergo a different process for those body color similarity checks. It allowed way more similar body colors than how it actually is.

I didn’t put the existance of the AvatarEditorService much into awarness. Based on AvatarEditorService:CheckApplyDefaultClothing’s description. It seems to return a HumanoidDescription, with the Default Clothing. Otherwise nil. I’ve not tried it.

If you don’t need the Default Clothing anymore, you’d have to remove the Shirt and Pants by yourself. Which isn’t really a problem if you have all of the default shirt asset ids, then erase them manually from the HumanoidDescription, I guess.

 

Currently, I am still looking forward to discover anything about Accessory Adjustment Refinement. Thus, would become more interesting later. The Avatar Lab Group had a testing experience, where they scaled the Avatar to fit inside bounding boxes. That’s very cool, probably a bit of math, but still cool.

If the working FFLag’s output would have actually functionally worked I’d have more to discover. The API’s meta doesn’t allow those Accessory metainfo yet. Other than it having a small pointless bug with no huge impact.

2 Likes