BGR <-> L*ab accuracy

I need to convert 32F images to L*ab color space, apply another algorithm (denoising), and then convert them back to BGR.

I have been having some problems that I think may be due to the accuracy of the BGR2LAB conversion. I did some investigating, and I may be right…

  1. I tried loading an RGB image (a Lena), converting to L*ab and then back again. The same image is not exactly recovered. The difference between the original and the final image is about 0.001 (0=black, 1=white), varying pixel to pixel. Code to demonstrate this is at the end. This error is extremely significant for me. It basically destroys my data.

  2. I compared the result of the L*ab conversion to various calculators. Again, they are close, but not quite the same. For example, using the code at the end, for (R,G,B)=(0.3,0.5,0.6), OpenCV gives L*ab of (50.866699, -9.875, -18.9375), compared to (50.58 -12.24 -19.41), or (50.88, -9.78, -18.99), and others. I’m not sure why there is the variation.

  3. I also tried writing my own BGR2L*ab conversion functions (at the end). For some reason, I haven’t been able to get that to work either, so this has turned out to be a dead end.

Is this the expected level of accuracy for the CvtColor function? Am I just expecting too much of it? Is there anything I can do to enhance its accuracy? If not, I will need to think of an alternative way to denoise luminance and color separately.

I also tried taking my data non-linear (applying a stretch), so that the error level would be less significant compared to the signal. This works, kind of, but the error is still there, and introduces strange artefacts, and limits the effectiveness of subsequent operations.

Comparing an image to itself after conversion to L*ab and back again:

int main(int argc,char** argv)
{
	cv::Mat image=imread(argv[1],cv::IMREAD_UNCHANGED);
	if(!image.empty()) // check for invalid input
	{
		if(image.channels()==4) cv::cvtColor(image,image,cv::COLOR_BGRA2BGR);
		if(image.depth()==CV_8U) image.convertTo(image,CV_32F,1.0/255.0); // convert 8 bit integer to 32 bit float scaling to range 0 to 1
		else if(image.depth()==CV_16U) image.convertTo(image,CV_32F,1.0/65535.0); // convert 16 bit integer to 32 bit float scaling to range 0 to 1
		else if(image.depth()==CV_16F) image.convertTo(image,CV_32F); // convert 16 bit integer to 32 bit float without rescaling
	}
	
	cv::Mat cvLab;
	cv::cvtColor(image,cvLab,cv::COLOR_BGR2Lab);
	
	cv::Mat backAgain;
	cv::cvtColor(cvLab,backAgain,cv::COLOR_Lab2BGR);
	
	std::cout<<image-backAgain<<std::endl;
	
	return 0;
}

Converting BGR pixel to L*ab and outputting the result:

	float r[1] = { 0.3 };
	float g[1] = { 0.5 };
	float b[1] = { 0.6 };
	cv::Mat R = cv::Mat(1, 1, CV_32F, r);
	cv::Mat G = cv::Mat(1, 1, CV_32F, g);
	cv::Mat B = cv::Mat(1, 1, CV_32F, b);
	std::vector<cv::Mat> channels;
	channels.push_back(B);
	channels.push_back(G);
	channels.push_back(R);
	cv::Mat image;
	cv::merge(channels,image);

	cv::Mat myLab;
	cv::cvtColor(image,myLab,cv::COLOR_BGR2Lab);
	std::cout<<myLab<<std::endl;

My own BGR2L*ab conversion function:

void BGR2Lab(const cv::Mat src,cv::Mat& dst)
{
	float c[9]={0.412453,0.357580,0.180423,
		0.212671,0.715160,0.072169,
		0.019334,0.119193,0.950227};
	
	std::vector<cv::Mat> planes;
	cv::split(src,planes);

	cv::Mat X=c[0]*planes[2]+c[1]*planes[1]+c[2]*planes[0];
	cv::Mat Y=c[3]*planes[2]+c[4]*planes[1]+c[5]*planes[0];
	cv::Mat Z=c[6]*planes[2]+c[7]*planes[1]+c[8]*planes[0];
	
	X/=0.950456;
	Z/=1.088754;
	
	cv::Mat mask=cv::Mat(Y.size(),CV_8UC1);
	cv::inRange(Y,0,0.008856,mask); // mask=white for pixels less than or equal to 0.008856
	
	cv::Mat L=cv::Mat::zeros(Y.size(),CV_32FC1);
	cv::pow(Y,1./3,L);
	L=116.*L-16.;
	L.setTo(0.,mask);
	
	cv::Mat L2=903.3*Y;
	cv::bitwise_not(mask,mask);
	L2.setTo(0.,mask);
	L+=L2;
	
	auto f=[](cv::Mat &t)
	{
		cv::Mat mask=cv::Mat(t.size(),CV_8UC1);
		cv::inRange(t,0,0.008856,mask); // mask=white for pixels less than or equal to 0.008856

		cv::Mat f=cv::Mat::zeros(t.size(),CV_32FC1);
		cv::pow(t,1./3,f);
		f.setTo(0,mask);
		
		cv::Mat f2=7.787*t+16./116;
		cv::bitwise_not(mask,mask);
		f2.setTo(0,mask);
		f+=f2;
		return f.clone();
	};
	
	cv::Mat fX=f(X);
	cv::Mat fY=f(Y);
	cv::Mat fZ=f(Z);
	cv::Mat a=500.*(fX-fY);
	cv::Mat b=200.*(fY-fZ);

	std::vector<cv::Mat> LabPlanes={ L,a,b };
	cv::merge(LabPlanes,dst);
}

https://docs.opencv.org/4.x/de/d25/imgproc_color_conversions.html#color_convert_rgb_lab

I gave this problem a break, and came back to it later. There were two problems, and it’s solved.

  1. I’m pretty sure the CvtColor function is not fully 32F compatible, despite what the manual says. My guess is that the data is at some point rounded into a reduced number of quantization levels introducing quantization error. I haven’t counted them, but if I convert my data into L*ab / HSV / HLS and directly back again, it has lost precision, and therefore, a lot of detail. I have re-implemented these functions maintaining floating-point arithmetic and 32F precision, it is seems to be fine now.

  2. My BGR2Lab function in my last post missed a key step that is not documented in the manual. I now understand that the RGB bands need inverse companding before they can be converted to XYZ and then to L*ab. I’ve not been interested in the details of color space conversion before, and so this detail had escaped me until now.