class ImagePropertiesTable(ImageGeometryTable): """Properties table for images, with geometry and additional statistical information. Allows automatic handling of tags in filenames, e.g., ZxYxX_u16be. """ @atable.column_function([ atable.ColumnProperties(name="sample_min", label="Min sample value"), atable.ColumnProperties(name="sample_max", label="Max sample value") ]) def set_sample_extrema(self, file_path, row): array = load_array_bsq(file_or_path=file_path, image_properties_row=row).flatten() row["sample_min"], row["sample_max"] = array.min(), array.max() if row["float"] == False: assert row["sample_min"] == int(row["sample_min"]) assert row["sample_max"] == int(row["sample_max"]) row["sample_min"] = int(row["sample_min"]) row["sample_max"] = int(row["sample_max"]) @atable.column_function("dynamic_range_bits", label="Dynamic range (bits)") def set_dynamic_range_bits(self, file_path, row): if row["float"] is True: range_len = 8 * row["bytes_per_sample"] else: range_len = int(row["sample_max"]) - int(row["sample_min"]) assert range_len >= 0, (file_path, row["sample_max"], row["sample_min"], range_len) row[_column_name] = max(1, math.ceil(math.log2(range_len + 1))) @atable.column_function("entropy_1B_bps", label="Entropy (bps, 1-byte samples)", plot_min=0, plot_max=8) def set_file_1B_entropy(self, file_path, row): """Return the zero-order entropy of the data in file_path (1-byte samples are assumed) """ row[_column_name] = entropy( np.fromfile(file_path, dtype="uint8").flatten()) @atable.column_function("entropy_2B_bps", label="Entropy (bps, 2-byte samples)", plot_min=0, plot_max=16) def set_file_2B_entropy(self, file_path, row): """Set the zero-order entropy of the data in file_path (2-byte samples are assumed) if bytes_per_sample is a multiple of 2, otherwise the column is set to -1 """ if row["bytes_per_sample"] % 2 != 0: row[_column_name] = -1 else: row[_column_name] = entropy( np.fromfile(file_path, dtype=np.uint16).flatten()) @atable.column_function( [f"byte_value_{s}" for s in ["min", "max", "avg", "std"]]) def set_byte_value_extrema(self, file_path, row): contents = np.fromfile(file_path, dtype="uint8") row["byte_value_min"] = contents.min() row["byte_value_max"] = contents.max() row["byte_value_avg"] = contents.mean() row["byte_value_std"] = contents.std()
class ImageGeometryTable(sets.FilePropertiesTable): """Basic properties table for images, including geometry. Allows automatic handling of tags in filenames, e.g., ZxYxX_u16be. """ @atable.column_function("bytes_per_sample", label="Bytes per sample", plot_min=0) def set_bytes_per_sample(self, file_path, row): if any(s in file_path for s in ("u8be", "u8le", "s8be", "s8le")): row[_column_name] = 1 elif any(s in file_path for s in ("u16be", "u16le", "s16be", "s16le", "f16")): row[_column_name] = 2 elif any(s in file_path for s in ("u32be", "u32le", "s32be", "s32le", "f32")): row[_column_name] = 4 elif any(s in file_path for s in ("f64")): row[_column_name] = 8 else: raise sets.UnkownPropertiesException( f"Unknown {_column_name} for {file_path}") @atable.column_function("float", label="Floating point data?") def set_float(self, file_path, row): if any(s in file_path for s in ("u8be", "u8le", "s8be", "s8le", "u16be", "u16le", "s16be", "s16le", "u32be", "u32le", "s32be", "s32le")): row[_column_name] = False elif any(s in file_path for s in ("f16", "f32", "f64")): row[_column_name] = True else: raise sets.UnkownPropertiesException( f"Unknown {_column_name} from {file_path}") @atable.column_function("signed", label="Signed samples") def set_signed(self, file_path, row): if any(s in file_path for s in ("u8be", "u16be", "u16le", "u32be", "u32le")): row[_column_name] = False elif any(s in file_path for s in ("s8be", "s16be", "s16le", "s32be", "s32le", "f16", "f32", "f64")): row[_column_name] = True else: raise sets.UnkownPropertiesException( f"Unknown {_column_name} for {file_path}") @atable.column_function("big_endian", label="Big endian?") def set_big_endian(self, file_path, row): if any(s in file_path for s in ("u8be", "u16be", "u32be", "s8be", "s16be", "s32be")): row[_column_name] = True elif any(s in file_path for s in ("u8le", "u16le", "u32le", "s8le", "s16le", "s32le")): row[_column_name] = False elif any(s in file_path for s in ("f16", "f32", "f64")): row[_column_name] = True else: raise sets.UnkownPropertiesException( f"Unknown {_column_name} for {file_path}") @atable.column_function("samples", label="Sample count", plot_min=0) def set_samples(self, file_path, row): """Set the number of samples in the image """ assert row["size_bytes"] % row["bytes_per_sample"] == 0 row[_column_name] = row["size_bytes"] // row["bytes_per_sample"] @atable.column_function([ atable.ColumnProperties(name="width", label="Width", plot_min=1), atable.ColumnProperties(name="height", label="Height", plot_min=1), atable.ColumnProperties(name="component_count", label="Components", plot_min=1), ]) def set_image_geometry(self, file_path, row): """Obtain the image's geometry (width, height and number of components) based on the filename tags (and possibly its size) """ file_path_to_geometry_dict(file_path=file_path, existing_dict=row)
class CompressionExperiment(experiment.Experiment): """This class allows seamless execution of compression experiments. In the functions decorated with @atable,column_function, the row argument contains two magic properties, compression_results and decompression_results. These give access to the :class:`CompressionResults` and :class:`DecompressionResults` instances resulting respectively from compressing and decompressing according to the row index parameters. The paths referenced in the compression and decompression results are valid while the row is being processed, and are disposed of afterwards. Also, the image_info_row attribute gives access to the image metainformation (e.g., geometry) """ default_file_properties_table_class = enb.isets.ImagePropertiesTable check_lossless = True class RowWrapper: """Rows passed as arguments to the table column functions of CompressionExperiment subclasses are of this type. This allows accessing the compression_results and decompression_results properties (see the CompressionResults and DecompressionResults classes), which automatically compress and decompress the image with the appropriate codec. Row names are set and retrieved normally with a dict-like syntax. """ def __init__(self, file_path, codec, image_info_row, row): self.file_path = file_path self.codec = codec self.image_info_row = image_info_row self.row = row self._compression_results = None self._decompression_results = None @property def compression_results(self): """Perform the actual compression experiment for the selected row. """ if self._compression_results is None: _, tmp_compressed_path = tempfile.mkstemp( dir=options.base_tmp_dir, prefix=f"compressed_{os.path.basename(self.file_path)}_") try: measured_times = [] if options.verbose > 1: print(f"[E]xecuting compression {self.codec.name} on {self.file_path} " f"[{options.repetitions} times]") for repetition_index in range(options.repetitions): if options.verbose > 2: print(f"[E]xecuting compression {self.codec.name} on {self.file_path} " f"[rep{repetition_index + 1}/{options.repetitions}]") time_before = time.time() self._compression_results = self.codec.compress(original_path=self.file_path, compressed_path=tmp_compressed_path, original_file_info=self.image_info_row) if not os.path.isfile(tmp_compressed_path) \ or os.path.getsize(tmp_compressed_path) == 0: raise CompressionException( original_path=self.file_path, compressed_path=tmp_compressed_path, file_info=self.image_info_row, output=f"Compression didn't produce a file (or it was empty) {self.file_path}") wall_compression_time = time.time() - time_before if self._compression_results is None: if options.verbose > 2: print(f"[W]arning: codec {self.codec.name} did not report execution times. " f"Using wall clock instead (might be inaccurate)") self._compression_results = self.codec.compression_results_from_paths( original_path=self.file_path, compressed_path=tmp_compressed_path) self._compression_results.compression_time_seconds = wall_compression_time measured_times.append(self._compression_results.compression_time_seconds) if repetition_index < options.repetitions - 1: os.remove(tmp_compressed_path) self._compression_results.compression_time_seconds = sum(measured_times) / len(measured_times) except Exception as ex: os.remove(tmp_compressed_path) raise ex return self._compression_results @property def decompression_results(self): """Perform the actual decompression experiment for the selected row. """ if self._decompression_results is None: _, tmp_reconstructed_path = tempfile.mkstemp( prefix=f"reconstructed_{os.path.basename(self.file_path)}", dir=options.base_tmp_dir) try: measured_times = [] if options.verbose > 1: print(f"[E]xecuting decompression {self.codec.name} on {self.file_path} " f"[{options.repetitions} times]") for repetition_index in range(options.repetitions): if options.verbose > 2: print(f"[E]xecuting decompression {self.codec.name} on {self.file_path} " f"[rep{repetition_index + 1}/{options.repetitions}]") time_before = time.time() self._decompression_results = self.codec.decompress( compressed_path=self.compression_results.compressed_path, reconstructed_path=tmp_reconstructed_path, original_file_info=self.image_info_row) wall_decompression_time = time.time() - time_before if self._decompression_results is None: if options.verbose > 2: print(f"[W]arning: codec {self.codec.name} did not report execution times. " f"Using wall clock instead (might be inaccurate)") self._decompression_results = self.codec.decompression_results_from_paths( compressed_path=self.compression_results.compressed_path, reconstructed_path=tmp_reconstructed_path) self._decompression_results.decompression_time_seconds = wall_decompression_time if not os.path.isfile(tmp_reconstructed_path) or os.path.getsize( self._decompression_results.reconstructed_path) == 0: raise CompressionException( original_path=self.compression_results.file_path, compressed_path=self.compression_results.compressed_path, file_info=self.image_info_row, output=f"Decompression didn't produce a file (or it was empty)" f" {self.compression_results.file_path}") measured_times.append(self._decompression_results.decompression_time_seconds) if repetition_index < options.repetitions - 1: os.remove(tmp_reconstructed_path) self._decompression_results.decompression_time_seconds = sum(measured_times) / len(measured_times) except Exception as ex: os.remove(tmp_reconstructed_path) raise ex return self._decompression_results @property def numpy_dtype(self): """Get the numpy dtype corresponding to the original image's data format """ return isets.iproperties_row_to_numpy_dtype(self.image_info_row) def __getitem__(self, item): return self.row[item] def __setitem__(self, key, value): self.row[key] = value def __delitem__(self, key): del self.row[key] def __contains__(self, item): return item in self.row def __del__(self): if self._compression_results is not None: try: os.remove(self._compression_results.compressed_path) except OSError: pass if self._decompression_results is not None: try: os.remove(self._decompression_results.reconstructed_path) except OSError: pass def __init__(self, codecs, dataset_paths=None, csv_experiment_path=None, csv_dataset_path=None, dataset_info_table=None, overwrite_file_properties=False, parallel_dataset_property_processing=None, reconstructed_dir_path=None, compressed_copy_dir_path=None): """ :param codecs: list of :py:class:`AbstractCodec` instances. Note that codecs are compatible with the interface of :py:class:`ExperimentTask`. :param dataset_paths: list of paths to the files to be used as input for compression. If it is None, this list is obtained automatically from the configured base dataset dir. :param csv_experiment_path: if not None, path to the CSV file giving persistence support to this experiment. If None, it is automatically determined within options.persistence_dir. :param csv_dataset_path: if not None, path to the CSV file given persistence support to the dataset file properties. If None, it is automatically determined within options.persistence_dir. :param dataset_info_table: if not None, it must be a ImagePropertiesTable instance or subclass instance that can be used to obtain dataset file metainformation, and/or gather it from csv_dataset_path. If None, a new ImagePropertiesTable instance is created and used for this purpose. :param overwrite_file_properties: if True, file properties are recomputed before starting the experiment. Useful for temporary and/or random datasets. Note that overwrite control for the experiment results themselves is controlled in the call to get_df :param parallel_dataset_property_processing: if not None, it determines whether file properties are to be obtained in parallel. If None, it is given by not options.sequential. :param reconstructed_dir_path: if not None, a directory where reconstructed images are to be stored. :param compressed_copy_dir_path: if not None, it gives the directory where a copy of the compressed images. is to be stored. If may not be generated for images for which all columns are known """ table_class = type(dataset_info_table) if dataset_info_table is not None \ else self.default_file_properties_table_class csv_dataset_path = csv_dataset_path if csv_dataset_path is not None \ else os.path.join(options.persistence_dir, f"{table_class.__name__}_persistence.csv") imageinfo_table = dataset_info_table if dataset_info_table is not None \ else table_class(csv_support_path=csv_dataset_path) csv_dataset_path = csv_dataset_path if csv_dataset_path is not None \ else f"{dataset_info_table.__class__.__name__}_persistence.csv" super().__init__(tasks=codecs, dataset_paths=dataset_paths, csv_experiment_path=csv_experiment_path, csv_dataset_path=csv_dataset_path, dataset_info_table=imageinfo_table, overwrite_file_properties=overwrite_file_properties, parallel_dataset_property_processing=parallel_dataset_property_processing) self.reconstructed_dir_path = reconstructed_dir_path self.compressed_copy_dir_path = compressed_copy_dir_path @property def codecs(self): """:return: an iterable of defined codecs """ return self.tasks_by_name.values() @codecs.setter def codecs(self, new_codecs): self.tasks_by_name = collections.OrderedDict({ codec.name: codec for codec in new_codecs}) @property def codecs_by_name(self): """Alias for :py:attr:`tasks_by_name` """ return self.tasks_by_name def process_row(self, index, column_fun_tuples, row, overwrite, fill): file_path, codec_name = index codec = self.codecs_by_name[codec_name] image_info_row = self.dataset_table_df.loc[indices_to_internal_loc(file_path)] row_wrapper = self.RowWrapper( file_path=file_path, codec=codec, image_info_row=image_info_row, row=row) result = super().process_row(index=index, column_fun_tuples=column_fun_tuples, row=row_wrapper, overwrite=overwrite, fill=fill) if isinstance(result, Exception): return result if self.compressed_copy_dir_path: output_compressed_path = os.path.join( self.compressed_copy_dir_path, codec.name, os.path.basename(os.path.dirname(file_path)), os.path.basename(file_path)) os.makedirs(os.path.dirname(output_compressed_path), exist_ok=True) if options.verbose > 1: print(f"[C]opying {file_path} into {output_compressed_path}") shutil.copy(row_wrapper.compression_results.compressed_path, output_compressed_path) if self.reconstructed_dir_path is not None: output_reconstructed_path = os.path.join( self.reconstructed_dir_path, codec.name, os.path.basename(os.path.dirname(file_path)), os.path.basename(file_path)) os.makedirs(os.path.dirname(output_reconstructed_path), exist_ok=True) if options.verbose > 1: print(f"[C]opying {row_wrapper.compression_results.compressed_path} into {output_reconstructed_path}") shutil.copy(row_wrapper.decompression_results.reconstructed_path, output_reconstructed_path) if image_info_row["component_count"] == 3: rendered_path = f"{output_reconstructed_path}.png" if not os.path.exists(rendered_path) or options.force: array = isets.load_array_bsq(file_or_path=row_wrapper.decompression_results.reconstructed_path, image_properties_row=image_info_row).astype(np.int) if options.reconstructed_size is not None: width, height, _ = array.shape array = array[ width // 2 - options.reconstructed_size // 2:width // 2 + options.reconstructed_size // 2, height // 2 - options.reconstructed_size // 2:height // 2 + options.reconstructed_size // 2, :] cmin = array.min() cmax = array.max() array = np.round((255 * (array.astype(np.int) - cmin) / (cmax - cmin))).astype("uint8") if options.verbose > 1: print(f"[R]endering {rendered_path}") numpngw.imwrite(rendered_path, array.swapaxes(0, 1)) else: full_array = isets.load_array_bsq( file_or_path=row_wrapper.decompression_results.reconstructed_path, image_properties_row=image_info_row).astype(np.int) if options.reconstructed_size is not None: width, height, _ = full_array.shape full_array = full_array[ width // 2 - options.reconstructed_size // 2:width // 2 + options.reconstructed_size // 2, height // 2 - options.reconstructed_size // 2:height // 2 + options.reconstructed_size // 2, :] for i, rendered_path in enumerate(f"{output_reconstructed_path}_component{i}.png" for i in range(image_info_row['component_count'])): if not os.path.exists(rendered_path) or options.force: array = full_array[:, :, i].squeeze().swapaxes(0, 1) cmin = array.min() cmax = array.max() array = np.round((255 * (array - cmin) / (cmax - cmin))).astype("uint8") if options.verbose > 1: print(f"[R]endering {rendered_path}") numpngw.imwrite(rendered_path, array) return row @atable.column_function("compressed_size_bytes", label="Compressed data size (Bytes)", plot_min=0) def set_compressed_data_size(self, index, row): row[_column_name] = os.path.getsize(row.compression_results.compressed_path) @atable.column_function([ atable.ColumnProperties(name="compression_ratio", label="Compression ratio", plot_min=0), atable.ColumnProperties(name="lossless_reconstruction", label="Lossless?"), atable.ColumnProperties(name="compression_time_seconds", label="Compression time (s)", plot_min=0), atable.ColumnProperties(name="decompression_time_seconds", label="Decompression time (s)", plot_min=0), atable.ColumnProperties(name="repetitions", label="Number of compression/decompression repetitions", plot_min=0), atable.ColumnProperties(name="compressed_file_sha256", label="Compressed file's SHA256") ]) def set_comparison_results(self, index, row): """Perform a compression-decompression cycle and store the comparison results """ file_path, codec_name = index row.image_info_row = self.dataset_table_df.loc[indices_to_internal_loc(file_path)] assert row.compression_results.compressed_path == row.decompression_results.compressed_path assert row.image_info_row["bytes_per_sample"] * row.image_info_row["samples"] \ == os.path.getsize(row.compression_results.original_path) hasher = hashlib.sha256() with open(row.compression_results.compressed_path, "rb") as compressed_file: hasher.update(compressed_file.read()) compressed_file_sha256 = hasher.hexdigest() row["lossless_reconstruction"] = filecmp.cmp(row.compression_results.original_path, row.decompression_results.reconstructed_path) assert row.compression_results.compression_time_seconds is not None row["compression_time_seconds"] = row.compression_results.compression_time_seconds assert row.decompression_results.decompression_time_seconds is not None row["decompression_time_seconds"] = row.decompression_results.decompression_time_seconds row["repetitions"] = options.repetitions row["compression_ratio"] = os.path.getsize(row.compression_results.original_path) / row["compressed_size_bytes"] row["compressed_file_sha256"] = compressed_file_sha256 @atable.column_function("bpppc", label="Compressed data rate (bpppc)", plot_min=0) def set_bpppc(self, index, row): row[_column_name] = 8 * row["compressed_size_bytes"] / row.image_info_row["samples"] @atable.column_function("compression_ratio_dr", label="Compression ratio", plot_min=0) def set_compression_ratio_dr(self, index, row): """Set the compression ratio calculated based on the dynamic range of the input samples, as opposed to 8*bytes_per_sample. """ row[_column_name] = (row.image_info_row["dynamic_range_bits"] * row.image_info_row["samples"]) \ / (8 * row["compressed_size_bytes"]) @atable.column_function( [atable.ColumnProperties(name="compression_efficiency_1byte_entropy", label="Compression efficiency (1B entropy)", plot_min=0), atable.ColumnProperties(name="compression_efficiency_2byte_entropy", label="Compression efficiency (2B entropy)", plot_min=0)]) def set_efficiency(self, index, row): compression_efficiency_1byte_entropy = \ row.image_info_row["entropy_1B_bps"] * row.image_info_row["size_bytes"] \ / (row["compressed_size_bytes"] * 8) compression_efficiency_2byte_entropy = \ row.image_info_row["entropy_2B_bps"] * (row.image_info_row["size_bytes"] / 2) \ / (row["compressed_size_bytes"] * 8) row["compression_efficiency_1byte_entropy"] = compression_efficiency_1byte_entropy row["compression_efficiency_2byte_entropy"] = compression_efficiency_2byte_entropy
class ImageGeometryTable(sets.FilePropertiesTable): """Basic properties table for images, including geometry. Allows automatic handling of tags in filenames, e.g., ZxYxX_u16be. """ dataset_files_extension = "raw" # Data type columns @atable.column_function("bytes_per_sample", label="Bytes per sample", plot_min=0) def set_bytes_per_sample(self, file_path, row): if any(s in file_path for s in ("u8be", "u8le", "s8be", "s8le")): row[_column_name] = 1 elif any(s in file_path for s in ("u16be", "u16le", "s16be", "s16le", "f16")): row[_column_name] = 2 elif any(s in file_path for s in ("u32be", "u32le", "s32be", "s32le", "f32")): row[_column_name] = 4 elif any(s in file_path for s in ("f64")): row[_column_name] = 8 else: raise sets.UnkownPropertiesException( f"{self.__class__.__name__}: unknown {_column_name} for {file_path}" ) @atable.column_function("float", label="Floating point data?") def set_float(self, file_path, row): if any(s in os.path.basename(file_path) for s in ("u8be", "u8le", "s8be", "s8le", "u16be", "u16le", "s16be", "s16le", "u32be", "u32le", "s32be", "s32le")): row[_column_name] = False elif any(s in os.path.basename(file_path) for s in ("f16", "f32", "f64")): row[_column_name] = True else: enb.logger.debug( f"Unknown {_column_name} from {file_path}. Setting to False.") row[_column_name] = False @atable.column_function("signed", label="Signed samples") def set_signed(self, file_path, row): if any(s in file_path for s in ("u8be", "u16be", "u16le", "u32be", "u32le")): row[_column_name] = False elif any(s in file_path for s in ("s8be", "s16be", "s16le", "s32be", "s32le", "f16", "f32", "f64")): row[_column_name] = True else: enb.logger.debug( f"Unknown {_column_name} for {file_path}. Setting to False.") row[_column_name] = False @atable.column_function("big_endian", label="Big endian?") def set_big_endian(self, file_path, row): if any(s in file_path for s in ("u8be", "u16be", "u32be", "s8be", "s16be", "s32be")): row[_column_name] = True elif any(s in file_path for s in ("u8le", "u16le", "u32le", "s8le", "s16le", "s32le")): row[_column_name] = False elif any(s in file_path for s in ("f16", "f32", "f64")): row[_column_name] = True else: enb.logger.debug( f"Unknown {_column_name} for {file_path}. Setting to False.") row[_column_name] = False @atable.column_function("dtype", label="Numpy dtype") def set_column_dtype(self, file_path, row): """Set numpy's dtype string """ if row["float"]: row[_column_name] = f"f{8 * row['bytes_per_sample']}" else: row[_column_name] = f"{'>' if row['big_endian'] else '<'}{'i' if row['signed'] else 'u'}{row['bytes_per_sample']}" @atable.column_function("type_name", label="Type name usable in file names") def set_type_name(self, file_path, row): """Set the type name usable in file names """ if row["float"]: row[_column_name] = f"f{8 * row['bytes_per_sample']}" else: row[_column_name] = f"{'s' if row['signed'] else 'u'}{8 * row['bytes_per_sample']}{'be' if row['big_endian'] else 'le'}" # Image dimension columns @atable.column_function("samples", label="Sample count", plot_min=0) def set_samples(self, file_path, row): """Set the number of samples in the image """ assert row["size_bytes"] % row["bytes_per_sample"] == 0 row[_column_name] = row["size_bytes"] // row["bytes_per_sample"] @atable.column_function([ atable.ColumnProperties(name="width", label="Width", plot_min=1), atable.ColumnProperties(name="height", label="Height", plot_min=1), atable.ColumnProperties(name="component_count", label="Components", plot_min=1), ]) def set_image_geometry(self, file_path, row): """Obtain the image's geometry (width, height and number of components) based on the filename tags (and possibly its size) """ file_path_to_geometry_dict(file_path=file_path, existing_dict=row)
class DefinitionModesTable(atable.ATable): """Example ATable subclass that exemplifies multiple ways of defining columns. """ def column_a(self, index, row): """Methods that start with column_* are automatically recognized as column definitions. The returned value is the value set into the appropriate column of row. """ return 1 def column_b(self, index, row): """Previously defined column values for this row can be used in other columns. Columns are computed in the order they are defined.""" return row["a"] + 1 @atable.column_function("c") def set_column_c(self, index, row): """The @enb.atable.column_function decorator can be used to explicitly mark class methods as column functions, i.e., functions that set one or more column functions. Functions decorated with @enb.atable.column_function must explicitly edit the row parameter to update the column function being set. """ row["c"] = row["b"] + 1 @atable.column_function("d") def set_column_d(self, index, row): """The _column_name is automatically be defined before calling a decorated (or column_*) function. This way, the function code needs not change if the column name is renamed. """ row[_column_name] = row["c"] + 1 def ignore_column_f(self, index, row): """Any number of methods can be defined normally in the ATable subclass. These are not invoked automatically by enb. """ raise Exception("This method is never invoked") @atable.column_function([ atable.ColumnProperties("e"), atable.ColumnProperties("f", label="label for f", plot_min=0, plot_max=10) ]) def set_multiple_colums(self, index, row): """Multiple columns can be set with the same decorated column function (not with undecorated column_* methods). The @enb.atable.column_function decorator accepts a list of atable.ColumnProperties instances, one per column being set by this function. Check out the documentation for atable.ColumnProperties, as it allows providing hints for plotting any column individually. See https://miguelinux314.github.io/experiment-notebook/using_analyzer_subclasses.html for more details. """ row["e"] = row["d"] + 1 row["f"] = row["e"] + 1 @atable.column_function( atable.ColumnProperties("constant_one", plot_min=1, plot_max=1), "constant_zero", atable.ColumnProperties("first_ten_numbers", has_iterable_values=True), atable.ColumnProperties("ascii_table", has_dict_values=True), ) def set_mixed_type_columns(self, index, row): row["constant_one"] = 1 row["constant_zero"] = 0 row["first_ten_numbers"] = list(range(10)) row["ascii_table"] = {l: ord(l) for l in string.ascii_letters}