Skip to content

Commit

Permalink
Add ability to backup and restore 1D LUT, 3D LUT and 3x3 color matrix
Browse files Browse the repository at this point in the history
  • Loading branch information
chros73 committed Feb 5, 2023
1 parent 14cfd81 commit 7d15832
Show file tree
Hide file tree
Showing 12 changed files with 84,851 additions and 64 deletions.
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ set_oled_light, set_contrast, set_brightness, set_color
upload_1d_lut, upload_1d_lut_from_file, set_1d_lut_en (?),
upload_3d_lut_bt709, upload_3d_lut_bt709_from_file, upload_3d_lut_bt2020, upload_3d_lut_bt2020_from_file,
set_1d_en_2_2, set_1d_en_0_45,
set_3by3_gamut_data_bt709, set_3by3_gamut_data_bt2020, set_3by3_gamut_data_hdr (only used in 2019 models), set_3by3_gamut_en (?),
set_3by3_gamut_data_bt709, set_3by3_gamut_data_bt2020, set_3by3_gamut_data_hdr (only used in 2019 models), set_3by3_gamut_data_from_file, set_3by3_gamut_en (?),
set_tonemap_params (for HDR10 picture modes),
set_dolby_vision_config_data (not recommended on >=2020 models!)
```
Expand Down Expand Up @@ -346,9 +346,10 @@ bscpylgtvcommand 192.168.1.18 end_calibration

#### Get calibration commands

- NOTE: it's completely broken in newer models (>=2020)
- NOTE: they can be partially or completely broken (depending on firmware version, picture preset, etc)
- they can be used inside or outside of calibration mode as well
- they return the data of the currently active picture mode
- in case of factory LUTs (sometimes even with custom LUTs as well), they return the post-processed LUTs with all customizations factored in (including customizations via the user settings menu or service menu)

The following commands are supported via calibration API:
```
Expand All @@ -367,6 +368,38 @@ bscpylgtvcommand 192.168.1.18 get_3by3_gamut_data
bscpylgtvcommand 192.168.1.18 end_calibration
```

##### Backup and restore 1D/3D LUTs and 3x3 color matrices

It's possible to backup and resture 1D/3D LUTs and 3x3 color matrices (if the getter interface of the calibration API works fine).

Example usage for backuping:
```bash
# Start calibration mode
bscpylgtvcommand 192.168.1.18 start_calibration hdr_cinema
# Backup 3x3 color matrix
bscpylgtvcommand 192.168.1.18 get_3by3_gamut_data "hdr_cinema_3x3.matrix"
# Backup 1D LUT
bscpylgtvcommand 192.168.1.18 get_1d_lut "hdr_cinema.1dlut"
# Backup 3D LUT
bscpylgtvcommand 192.168.1.18 get_3d_lut "hdr_cinema.3dlut"
# End calibration mode
bscpylgtvcommand 192.168.1.18 end_calibration
```

Example usage for restoring:
```bash
# Start calibration mode
bscpylgtvcommand 192.168.1.18 start_calibration hdr_cinema
# Restore 3x3 color matrix
bscpylgtvcommand 192.168.1.18 set_3by3_gamut_data_from_file "bt2020" "hdr_cinema_3x3.matrix"
# Restore 1D LUT
bscpylgtvcommand 192.168.1.18 upload_1d_lut_from_file "hdr_cinema.1dlut"
# Restore 3D LUT
bscpylgtvcommand 192.168.1.18 upload_3d_lut_bt2020_from_file "hdr_cinema.3dlut"
# End calibration mode
bscpylgtvcommand 192.168.1.18 end_calibration
```

#### Uploading custom tonemapping parameters for HDR10 presets

- available only on supported models (>=2019)
Expand Down
8 changes: 8 additions & 0 deletions bscpylgtv/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
generate_dolby_vision_config,
read_cal_file,
read_cube_file,
read_1dlut_file,
read_3by3_gamut_file,
read_3dlut_file,
backup_lut_into_file,
unity_lut_1d,
unity_lut_3d,
)
Expand All @@ -31,6 +35,10 @@
"generate_dolby_vision_config",
"read_cal_file",
"read_cube_file",
"read_1dlut_file",
"read_3by3_gamut_file",
"read_3dlut_file",
"backup_lut_into_file",
"unity_lut_1d",
"unity_lut_3d",
])
56 changes: 56 additions & 0 deletions bscpylgtv/lut_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,62 @@ def read_cal_file(filename):
return lut


def backup_lut_into_file(filename, data):
if data.shape == (3,3):
np.savetxt(filename, data, fmt='%f')
elif data.shape == (3,1024):
np.savetxt(filename, data, fmt='%i')
else:
with open(filename, 'w') as outfile:
outfile.write('# Array shape: {0}\n'.format(data.shape))
for data_slice in data:
outfile.write('# New slice\n')
for sub_slice in data_slice:
outfile.write('# New sub slice\n')
np.savetxt(outfile, sub_slice, fmt='%i')

return True


def read_3by3_gamut_file(filename):
lut = np.loadtxt(filename, dtype=np.float32)
shape = (3,3)
range = (-1024, 1024)

if not isinstance(lut, np.ndarray) or lut.shape != shape:
raise ValueError(f"3by3 Gamut should have shape {shape} but instead has {lut.shape}")
if ((lut >= range[0]).all() != (lut <= range[1]).all()):
raise ValueError(f"values in 3by3 Gamut must be between {range[0]} and {range[1]}")

return lut


def read_1dlut_file(filename):
lut = np.loadtxt(filename, dtype=np.uint16)
shape = (3,1024)
range = (0,32767)

if not isinstance(lut, np.ndarray) or lut.shape != shape:
raise ValueError(f"1D LUT should have shape {shape} but instead has {lut.shape}")
if ((lut >= range[0]).all() != (lut <= range[1]).all()):
raise ValueError(f"values in 1D LUT must be between {range[0]} and {range[1]}")

return lut


def read_3dlut_file(filename, lut3d_size):
shape = (lut3d_size, lut3d_size, lut3d_size, 3)
range = (0, 4095)
lut = np.loadtxt(filename, dtype=np.uint16).reshape(shape)

if not isinstance(lut, np.ndarray) or lut.shape != shape:
raise ValueError(f"3D LUT should have shape {shape} but instead has {lut.shape}")
if ((lut >= range[0]).all() != (lut <= range[1]).all()):
raise ValueError(f"values in 3D LUT must be between {range[0]} and {range[1]}")

return lut


def lms2rgb_matrix(primaries=BT2020_PRIMARIES):
xy = np.array(primaries, dtype=np.float64)

Expand Down
69 changes: 54 additions & 15 deletions bscpylgtv/webos_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
generate_dolby_vision_config,
read_cal_file,
read_cube_file,
read_1dlut_file,
read_3by3_gamut_file,
read_3dlut_file,
backup_lut_into_file,
unity_lut_1d,
unity_lut_3d,
)
Expand Down Expand Up @@ -2329,9 +2333,11 @@ def validateCalibrationData(self, data, shape, dtype, range=None, count=None):
if isinstance(count, int) and data.size != count:
raise ValueError(f"data should have size {count} but instead has {data.size}")

async def get_calibration_data(self, command, shape):
async def get_calibration_data(self, command, shape, filename=""):
if command not in [cal.GET_GAMMA_2_2_TRANSFORM, cal.GET_GAMMA_0_45_TRANSFORM, cal.GET_3BY3_GAMUT_DATA, cal.GET_HDR_3BY3_GAMUT_DATA, cal.GET_1D_LUT, cal.GET_3D_LUT]:
raise PyLGTVCmdException(f"Invalid Get Calibration command {command}.")
if filename and filename.split(".")[-1].lower() not in ["1dlut", "matrix", "3dlut"]:
raise PyLGTVCmdException(f"Invalid Get Calibration file extension, must be: 1dlut or matrix or 3dlut.")

response = await self.request(ep.GET_CALIBRATION, {"command": command})

Expand All @@ -2349,30 +2355,36 @@ async def get_calibration_data(self, command, shape):
data = np.reshape(deserialized_bytes, newshape=shape)
self.validateCalibrationData(data, shape, npType, None, dataCount)

# print the full numpy array
np.set_printoptions(threshold=np.inf)
return data if shape != (1, ) else data[0]
if filename:
# backup numpy array
return await asyncio.get_running_loop().run_in_executor(
None, backup_lut_into_file, filename, data
)
else:
# print the full numpy array
np.set_printoptions(threshold=np.inf)
return data if shape != (1, ) else data[0]

async def get_1d_en_2_2(self):
return await self.get_calibration_data(cal.GET_GAMMA_2_2_TRANSFORM, (1, ))

async def get_1d_en_0_45(self):
return await self.get_calibration_data(cal.GET_GAMMA_0_45_TRANSFORM, (1, ))

async def get_3by3_gamut_data(self):
return await self.get_calibration_data(cal.GET_3BY3_GAMUT_DATA, (3, 3))
async def get_3by3_gamut_data(self, filename=""):
return await self.get_calibration_data(cal.GET_3BY3_GAMUT_DATA, (3, 3), filename)

async def get_3by3_gamut_data_hdr(self):
return await self.get_calibration_data(cal.GET_HDR_3BY3_GAMUT_DATA, (3, 3))
async def get_3by3_gamut_data_hdr(self, filename=""):
return await self.get_calibration_data(cal.GET_HDR_3BY3_GAMUT_DATA, (3, 3), filename)

async def get_1d_lut(self):
return await self.get_calibration_data(cal.GET_1D_LUT, (3, 1024))
async def get_1d_lut(self, filename=""):
return await self.get_calibration_data(cal.GET_1D_LUT, (3, 1024), filename)

async def get_3d_lut(self):
async def get_3d_lut(self, filename=""):
self.check_calibration_support("lut3d", "3D LUT Upload")
lut3d_size = self._calibration_info["lut3d"]
lut3d_shape = (lut3d_size, lut3d_size, lut3d_size, 3)
return await self.get_calibration_data(cal.GET_3D_LUT, lut3d_shape)
return await self.get_calibration_data(cal.GET_3D_LUT, lut3d_shape, filename)

async def calibration_request(self, command, data=None, dataOpt=1, picture_mode=None):
# dataOpt: 0 - Apply, 1 - Apply and Save, 2 - Reset
Expand Down Expand Up @@ -2450,9 +2462,13 @@ async def upload_1d_lut_from_file(self, filename):
lut = await asyncio.get_running_loop().run_in_executor(
None, read_cube_file, filename
)
elif ext == "1dlut":
lut = await asyncio.get_running_loop().run_in_executor(
None, read_1dlut_file, filename
)
else:
raise ValueError(
f"Unsupported file format {ext} for 1D LUT. Supported file formats are cal and cube."
f"Unsupported file format {ext} for 1D LUT. Supported file formats are cal, cube and 1dlut."
)

return await self.upload_1d_lut(lut)
Expand Down Expand Up @@ -2514,9 +2530,14 @@ async def upload_3d_lut_from_file(self, command, filename):
lut = await asyncio.get_running_loop().run_in_executor(
None, read_cube_file, filename
)
elif ext == "3dlut":
self.check_calibration_support("lut3d", "3D LUT Upload")
lut = await asyncio.get_running_loop().run_in_executor(
None, read_3dlut_file, filename, self._calibration_info["lut3d"]
)
else:
raise ValueError(
f"Unsupported file format {ext} for 3D LUT. Supported file formats are cube."
f"Unsupported file format {ext} for 3D LUT. Supported file formats are cube and 3dlut."
)

return await self.upload_3d_lut(command, lut)
Expand Down Expand Up @@ -2546,7 +2567,7 @@ async def set_3by3_gamut_data(self, command, data):
else:
if data is None:
data = np.identity(3, dtype=np.float32)
else:
elif not isinstance(data, np.ndarray):
data = np.array(data, dtype=np.float32)
self.validateCalibrationData(data, (3, 3), np.float32, (-1024, 1024))
dataOpt = 1
Expand All @@ -2565,6 +2586,24 @@ async def set_3by3_gamut_data_hdr(self, data=None):
"""Set HDR 3x3 color matrix used only in 2019 models (color gamut space transformation in linear space)."""
return await self.set_3by3_gamut_data(cal.HDR_3BY3_GAMUT_DATA, data)

async def set_3by3_gamut_data_from_file(self, type, filename):
methodName = f'set_3by3_gamut_data_{type}'
if not callable(getattr(self, methodName, None)):
raise PyLGTVCmdException(f"Invalid 3by3 gamut type {type}, must be: bt709 or bt2020 or hdr")

ext = filename.split(".")[-1].lower()
if ext == "matrix":
lut = await asyncio.get_running_loop().run_in_executor(
None, read_3by3_gamut_file, filename
)
else:
raise ValueError(
f"Unsupported file format {ext} for 3by3 gamut. Supported file format is matrix."
)

method = getattr(self, methodName)
return await method(lut)

async def set_3by3_gamut_en(self, enable=False):
"""Toggle 3x3 color matrix flag (color gamut space transformation in linear space)."""
return await self.toggle_calibration_flag(cal.ENABLE_3BY3_GAMUT, enable)
Expand Down
Loading

0 comments on commit 7d15832

Please sign in to comment.