__doc__ = """\
ClassifyObjects
===============

**ClassifyObjects** classifies objects into different classes according
to the value of measurements you choose.

In **measurement** mode, this module classifies objects into a number of 
different bins according to the value of a measurement (e.g., by size, 
intensity, shape). It reports how many objects fall into each class as 
well as the percentage of objects that fall into each class. The module 
asks you to select the measurement feature to be used to classify your 
objects and specify the bins to use. It also requires you to have run 
a measurement or **CalculateMath** previous to this module in the 
pipeline so that the measurement values can be used to classify the 
objects.

There are two flavors of measurement-based classification:

-  The first classifies each object according to the measurements you
   choose and assigns each object to one class per measurement. You may
   specify more than two classification bins per measurement.
-  The second classifies each object according to two measurements and
   two threshold values. The module classifies each object once per
   measurement resulting in four possible object classes. The module
   then stores one measurement per object, based on the object’s class.

Note that objects without a measurement are not counted as belonging in
a classification bin and will not show up in the output image (shown in
the module display window); in the object classification they will have
a value of False for all bins. However, they are still counted in the
total number of objects and hence are reflected in the classification
percentages.

In **model** mode, this module will classify objects into distinct classes
as determined by the machine learning model the user supplies. Models 
can be trained and exported using the Classifier tool in CellProfiler 
Analyst. Only models trained in CellProfiler Analyst 3.0+ will be 
compatible. To use a model, all features which were available within 
Analyst must also be produced within the pipeline before running 
ClassifyObjects.

Model mode also allows you to create new object sets from each class 
which is generated by the classifier.
 
|		
 		
============ ============ ===============		
Supports 2D? Supports 3D? Respects masks?		
============ ============ ===============		
YES          NO           NO		
============ ============ ===============		

See also		
^^^^^^^^		

See also **CalculateMath** and any of the modules in the **Measure**	category.		
 
Measurements made by this module
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

**Measurement Mode:**

-  **Image measurements:**

   -  *NumObjectsPerBin:* The number of objects that are classified into
      each bin.
   -  *PctObjectsPerBin:* The percentage of total objects that are
      classified into each bin.

-  **Object measurements:**

   -  Single measurement: Classification (true/false) of the
      N\ :sup:`th` bin for the M\ :sup:`th` measurement.
   -  Two measurement: Classification (true/false) of the 1\ :sup:`st`
      measurement versus the 2\ :sup:`nd` measurement binned into bins
      above (“high”) and below (“low”) the cutoff.
      
**Model Mode:**

-  **Image measurements:**

   -  *NumObjectsPerClass:* The number of objects that are classified into
      each class.

-  **Object measurements:**

   -  *Class:* The name of the class which each object was assigned to.
   -  *ProbabilityPerClass:* With model files, this represents the 0-1 
      probability that an object belonged to each class. This gives an idea 
      of how confident the model was in classifying the object. When using 
      legacy rules.txt files or FastGentleBoosting models, this will instead 
      measure the absolute score for each class. The scoring scale is arbitrary,
      but objects are assigned to the highest scoring class. 

"""

import functools
import os

import numpy

from cellprofiler.modules import _help
import cellprofiler.utilities.rules
from cellprofiler_core.constants.measurement import COLTYPE_FLOAT, M_LOCATION_CENTER_X, M_LOCATION_CENTER_Y, \
    COLTYPE_VARCHAR, C_COUNT, C_CHILDREN, C_LOCATION, C_NUMBER, C_PARENT, FTR_OBJECT_NUMBER, FTR_CENTER_X, FTR_CENTER_Y, \
    FTR_CENTER_Z
from cellprofiler_core.constants.measurement import COLTYPE_INTEGER
from cellprofiler_core.constants.measurement import IMAGE
from cellprofiler_core.image import Image
from cellprofiler_core.module import Module
from cellprofiler_core.object import Objects
from cellprofiler_core.preferences import get_default_colormap, get_headless
from cellprofiler_core.setting import Binary
from cellprofiler_core.setting import Divider
from cellprofiler_core.setting import HiddenCount
from cellprofiler_core.setting import Measurement
from cellprofiler_core.setting import SettingsGroup
from cellprofiler_core.setting import ValidationError
from cellprofiler_core.setting.choice import Choice
from cellprofiler_core.setting.do_something import DoSomething
from cellprofiler_core.setting.do_something import RemoveSettingButton
from cellprofiler_core.setting.subscriber import LabelSubscriber
from cellprofiler_core.setting.text import Alphanumeric, Directory, Filename, LabelName
from cellprofiler_core.setting.text import Float
from cellprofiler_core.setting.text import ImageName
from cellprofiler_core.setting.text import Integer
from cellprofiler_core.setting.text import Text

from cellprofiler_core.constants.measurement import FF_CHILDREN_COUNT
from cellprofiler_core.constants.measurement import FF_COUNT
from cellprofiler_core.constants.measurement import FF_PARENT

from cellprofiler_core.module.image_segmentation import ImageSegmentation

BY_SINGLE_MEASUREMENT = "Single measurement"
BY_TWO_MEASUREMENTS = "Pair of measurements"
BY_MODEL = "Classifier Model"
TM_MEAN = "Mean"
TM_MEDIAN = "Median"
TM_CUSTOM = "Custom"

BC_EVEN = "Evenly spaced bins"
BC_CUSTOM = "Custom-defined bins"

M_CATEGORY = "Classify"
F_PCT_PER_BIN = "PctObjectsPerBin"
F_NUM_PER_BIN = "NumObjectsPerBin"


class ClassifyObjects(Module):
    category = "Object Processing"
    module_name = "ClassifyObjects"
    variable_revision_number = 3

    def create_settings(self):
        """Create the settings for the module

        Create the settings for the module during initialization.
        """
        self.contrast_choice = Choice(
            "Make classification decision based on",
            [BY_SINGLE_MEASUREMENT, BY_TWO_MEASUREMENTS, BY_MODEL],
            doc="""\
This setting controls how many measurements are used to make a
classifications decision for each object:

-  *%(BY_SINGLE_MEASUREMENT)s:* Classifies each object based on a
   single measurement.
-  *%(BY_TWO_MEASUREMENTS)s:* Classifies each object based on a pair
   of measurements taken together (that is, an object must meet two
   criteria to belong to a class).
-  *%(BY_MODEL)s:* Classifies each object based on a machine learning
   .model or .rules file produced by CellProfiler Analyst.

"""
            % globals(),
        )

        ############### Single measurement settings ##################
        #
        # A list holding groupings for each of the single measurements
        # to be done
        #
        self.single_measurements = []
        #
        # A count of # of measurements
        #
        self.single_measurement_count = HiddenCount(self.single_measurements)
        #
        # Add one single measurement to start off
        #
        self.add_single_measurement(False)
        #
        # A button to press to get another measurement
        #
        self.add_measurement_button = DoSomething(
            "", "Add another classification", self.add_single_measurement
        )
        #
        ############### Two-measurement settings #####################
        #
        # The object for the contrasting method
        #
        self.object_name = LabelSubscriber(
            "Select the object name",
            "None",
            doc="""\
Choose the object that you want to measure from the list. This should be
an object created by a previous module such as
**IdentifyPrimaryObjects**, **IdentifySecondaryObjects**, **IdentifyTertiaryObjects**, or **Watershed**
""",
        )

        #
        # The two measurements for the contrasting method
        #
        def object_fn():
            return self.object_name.value

        self.first_measurement = Measurement(
            "Select the first measurement",
            object_fn,
            doc="""\
*(Used only if using a pair of measurements)*

Choose a measurement made on the above object. This is the first of two
measurements that will be contrasted together. The measurement should be
one made on the object in a prior module.
""",
        )

        self.first_threshold_method = Choice(
            "Method to select the cutoff",
            [TM_MEAN, TM_MEDIAN, TM_CUSTOM],
            doc="""\
*(Used only if using a pair of measurements)*

Objects are classified as being above or below a cutoff value for a
measurement. You can set this cutoff threshold in one of three ways:

-  *%(TM_MEAN)s*: At the mean of the measurement’s value for all
   objects in the image cycle.
-  *%(TM_MEDIAN)s*: At the median of the measurement’s value for all
   objects in the image set.
-  *%(TM_CUSTOM)s*: You specify a custom threshold value.
"""
            % globals(),
        )

        self.first_threshold = Float(
            "Enter the cutoff value",
            0.5,
            doc="""\
*(Used only if using a pair of measurements)*

This is the cutoff value separating objects in the two classes.""",
        )

        self.second_measurement = Measurement(
            "Select the second measurement",
            object_fn,
            doc="""\
*(Used only if using a pair of measurements)*

Select a measurement made on the above object. This is
the second of two measurements that will be contrasted together.
The measurement should be one made on the object in a prior
module.""",
        )

        self.second_threshold_method = Choice(
            "Method to select the cutoff",
            [TM_MEAN, TM_MEDIAN, TM_CUSTOM],
            doc="""\
*(Used only if using a pair of measurements)*

Objects are classified as being above or below a cutoff value for a
measurement. You can set this cutoff threshold in one of three ways:

-  *%(TM_MEAN)s:* At the mean of the measurement’s value for all
   objects in the image cycle.
-  *%(TM_MEDIAN)s:* At the median of the measurement’s value for all
   objects in the image set.
-  *%(TM_CUSTOM)s:* You specify a custom threshold value.
"""
            % globals(),
        )

        self.second_threshold = Float(
            "Enter the cutoff value",
            0.5,
            doc="""\
*(Used only if using a pair of measurements)*

This is the cutoff value separating objects in the two classes.""",
        )

        self.wants_custom_names = Binary(
            "Use custom names for the bins?",
            False,
            doc="""\
*(Used only if using a pair of measurements)*

Select "*Yes*" if you want to specify the names of each bin
measurement.

Select "*No*" to create names based on the measurements. For instance,
for “Intensity_MeanIntensity_Green” and
“Intensity_TotalIntensity_Blue”, the module generates measurements
such as
“Classify_Intensity_MeanIntensity_Green_High_Intensity_TotalIntensity_Low”.
""",
        )

        self.low_low_custom_name = Alphanumeric(
            "Enter the low-low bin name",
            "low_low",
            doc="""\
*(Used only if using a pair of measurements)*

Name of the measurement for objects that fall below the threshold for
both measurements.
""",
        )

        self.low_high_custom_name = Alphanumeric(
            "Enter the low-high bin name",
            "low_high",
            doc="""\
*(Used only if using a pair of measurements)*

Name of the measurement for objects whose
first measurement is below threshold and whose second measurement
is above threshold.
""",
        )

        self.high_low_custom_name = Alphanumeric(
            "Enter the high-low bin name",
            "high_low",
            doc="""\
*(Used only if using a pair of measurements)*

Name of the measurement for objects whose
first measurement is above threshold and whose second measurement
is below threshold.""",
        )

        self.high_high_custom_name = Alphanumeric(
            "Enter the high-high bin name",
            "high_high",
            doc="""\
*(Used only if using a pair of measurements)*

Name of the measurement for objects that
are above the threshold for both measurements.""",
        )

        self.wants_image = Binary(
            "Retain an image of the classified objects?",
            False,
            doc="""\
Select "*Yes*" to retain the image of the objects color-coded
according to their classification, for use later in the pipeline (for
example, to be saved by a **SaveImages** module).
""",
        )

        self.image_name = ImageName(
            "Enter the image name",
            "None",
            doc="""\
*(Used only if the classified object image is to be retained for later use in the pipeline)*

Enter the name to be given to the classified object image.""",
        )

    def add_single_measurement(self, can_delete=True):
        """Add a single measurement to the group of single measurements

        can_delete - True to include a "remove" button, False if you're not
                     allowed to remove it.
        """
        group = SettingsGroup()
        if can_delete:
            group.append("divider", Divider(line=True))

        group.append(
            "object_name",
            LabelSubscriber(
                "Select the object to be classified",
                "None",
                doc="""\
The name of the objects to be classified. You can choose from objects
created by any previous module. See **IdentifyPrimaryObjects**,
**IdentifySecondaryObjects**, **IdentifyTertiaryObjects**, or **Watershed**
""",
            ),
        )

        def object_fn():
            return group.object_name.value

        group.append(
            "measurement",
            Measurement(
                "Select the measurement to classify by",
                object_fn,
                doc="""\
*(Used only if using a single measurement)*

Select a measurement made by a previous module. The objects will be
classified according to their values for this measurement.
""",
            ),
        )

        group.append(
            "bin_choice",
            Choice(
                "Select bin spacing",
                [BC_EVEN, BC_CUSTOM],
                doc="""\
*(Used only if using a single measurement)*

Select how you want to define the spacing of the bins. You have the
following options:

-  *%(BC_EVEN)s:* Choose this if you want to specify bins of equal
   size, bounded by upper and lower limits. If you want two bins, choose
   this option and then provide a single threshold when asked.
-  *%(BC_CUSTOM)s:* Choose this option to create the indicated number
   of bins at evenly spaced intervals between the low and high
   threshold. You also have the option to create bins for objects that
   fall below or above the low and high threshold.
"""
                % globals(),
            ),
        )

        group.append(
            "bin_count",
            Integer(
                "Number of bins",
                3,
                minval=1,
                doc="""\
*(Used only if using a single measurement)*

This is the number of bins that will be created between
the low and high threshold""",
            ),
        )

        group.append(
            "low_threshold",
            Float(
                "Lower threshold",
                0,
                doc="""\
*(Used only if using a single measurement and "%(BC_EVEN)s" selected)*

This is the threshold that separates the lowest bin from the others. The
lower threshold, upper threshold, and number of bins define the
thresholds of bins between the lowest and highest.
"""
                % globals(),
            ),
        )

        group.append(
            "wants_low_bin",
            Binary(
                "Use a bin for objects below the threshold?",
                False,
                doc="""\
*(Used only if using a single measurement)*

Select "*Yes*" if you want to create a bin for objects whose values
fall below the low threshold. Select "*No*" if you do not want a bin
for these objects.
""",
            ),
        )

        group.append(
            "high_threshold",
            Float(
                "Upper threshold",
                1,
                doc="""\
*(Used only if using a single measurement and "%(BC_EVEN)s" selected)*

This is the threshold that separates the last bin from the others. Note
that if you would like two bins, you should select "*%(BC_CUSTOM)s*".
"""
                % globals(),
            ),
        )

        group.append(
            "wants_high_bin",
            Binary(
                "Use a bin for objects above the threshold?",
                False,
                doc="""\
*(Used only if using a single measurement)*

Select "*Yes*" if you want to create a bin for objects whose values
are above the high threshold.

Select "*No*" if you do not want a bin for these objects.
""",
            ),
        )

        group.append(
            "custom_thresholds",
            Text(
                "Enter the custom thresholds separating the values between bins",
                "0,1",
                doc="""\
*(Used only if using a single measurement and "%(BC_CUSTOM)s" selected)*

This setting establishes the threshold values for the bins. You should
enter one threshold between each bin, separating thresholds with commas
(for example, *0.3, 1.5, 2.1* for four bins). The module will create one
more bin than there are thresholds.
"""
                % globals(),
            ),
        )

        group.append(
            "wants_custom_names",
            Binary(
                "Give each bin a name?",
                False,
                doc="""\
*(Used only if using a single measurement)*

Select "*Yes*" to assign custom names to bins you have specified.

Select "*No*" for the module to automatically assign names based on
the measurements and the bin number.
""",
            ),
        )

        group.append(
            "bin_names",
            Text(
                "Enter the bin names separated by commas",
                "None",
                doc="""\
*(Used only if "Give each bin a name?" is checked)*

Enter names for each of the bins, separated by commas.
An example including three bins might be *First,Second,Third*.""",
            ),
        )

        group.append(
            "wants_images",
            Binary(
                "Retain an image of the classified objects?",
                False,
                doc="""\
Select "*Yes*" to keep an image of the objects which is color-coded
according to their classification, for use later in the pipeline (for
example, to be saved by a **SaveImages** module).
""",
            ),
        )

        group.append(
            "image_name",
            ImageName(
                "Name the output image",
                "ClassifiedNuclei",
                doc="""Enter the name to be given to the classified object image.""",
            ),
        )

        group.can_delete = can_delete

        def number_of_bins():
            """Return the # of bins in this classification"""
            if group.bin_choice == BC_EVEN:
                value = group.bin_count.value
            else:
                value = len(group.custom_thresholds.value.split(",")) - 1
            if group.wants_low_bin:
                value += 1
            if group.wants_high_bin:
                value += 1
            return value

        group.number_of_bins = number_of_bins

        def measurement_name():
            """Get the measurement name to use inside the bin name

            Account for conflicts with previous measurements
            """
            measurement_name = group.measurement.value
            other_same = 0
            for other in self.single_measurements:
                if id(other) == id(group):
                    break
                if other.measurement.value == measurement_name:
                    other_same += 1
            if other_same > 0:
                measurement_name += str(other_same)
            return measurement_name

        def bin_feature_names():
            """Return the feature names for each bin"""
            if group.wants_custom_names:
                return [name.strip() for name in group.bin_names.value.split(",")]
            return [
                "_".join((measurement_name(), "Bin_%d" % (i + 1)))
                for i in range(number_of_bins())
            ]

        group.bin_feature_names = bin_feature_names

        def validate_group():
            bin_name_count = len(bin_feature_names())
            bin_count = number_of_bins()
            if bin_count < 1:
                bad_setting = (
                    group.bin_count
                    if group.bin_choice == BC_EVEN
                    else group.custom_thresholds
                )
                raise ValidationError(
                    "You must have at least one bin in order to take measurements. "
                    "Either add more bins or ask for bins for objects above or below threshold",
                    bad_setting,
                )
            if bin_name_count != number_of_bins():
                raise ValidationError(
                    "The number of bin names (%d) does not match the number of bins (%d)."
                    % (bin_name_count, bin_count),
                    group.bin_names,
                )
            for bin_feature_name in bin_feature_names():
                Alphanumeric.validate_alphanumeric_text(
                    bin_feature_name, group.bin_names, True
                )
            if group.bin_choice == BC_CUSTOM:
                try:
                    [float(x.strip()) for x in group.custom_thresholds.value.split(",")]
                except ValueError:
                    raise ValidationError(
                        "Custom thresholds must be a comma-separated list "
                        'of numbers (example: "1.0, 2.3, 4.5")',
                        group.custom_thresholds,
                    )
            elif group.bin_choice == BC_EVEN:
                if group.low_threshold.value >= group.high_threshold.value:
                    raise ValidationError(
                        "Lower Threshold must be less than Upper Threshold",
                        group.low_threshold,
                    )

        group.validate_group = validate_group

        if can_delete:
            group.remove_settings_button = RemoveSettingButton(
                "", "Remove this classification", self.single_measurements, group
            )
        self.single_measurements.append(group)

        self.model_directory = Directory(
            "Select the location of the classifier model file",
            doc=f"""\
            *(Used only when using {BY_MODEL} mode)*

            Select the location of the classifier file that will be used for
            classification.

            {_help.IO_FOLDER_CHOICE_HELP_TEXT}
            """,
        )

        self.create_class_sets = Binary(
            "Save classes as new object sets?",
            False,
            doc="Choose whether to create new object sets from classes specified by the classifier model",
        )

        def get_directory_fn():
            """Get the directory for the rules file name"""
            return self.model_directory.get_absolute_path()

        def set_directory_fn(path):
            dir_choice, custom_path = self.model_directory.get_parts_from_path(path)

            self.model_directory.join_parts(dir_choice, custom_path)
            if not get_headless():
                import wx
                wx.CallAfter(update_choices)

        def update_choices():
            # Very hacky, but we can force the UI to update the list of available classes
            # by running the Choice object validation function. Otherwise new class names
            # won't be available until the user changes another setting.
            for groupid in self.desired_classes:
                try:
                    groupid.class_name.test_valid(None)
                except:
                    # There will almost always be errors, but we just want the box updated.
                    pass

        self.model_file_name = Filename(
            "Rules or classifier file name",
            "mymodel.model",
            get_directory_fn=get_directory_fn,
            set_directory_fn=set_directory_fn,
            doc=f"""\
*(Used only when using {BY_MODEL} mode)*

The name of the classifier model file.

A classifier file is a trained classifier exported from CellProfiler Analyst.
You will need to ensure that the measurements specified by the file are
produced by upstream modules in the pipeline. This setting is not compatible
with data processed as 3D.

This should either be a .model file exported from CPA, or a .txt file featuring 
rules from CPA's FastGentleBoosting classifier type.

If you're working with sklearn outside of CPA and want to build your own model, 
the saved object should be a tuple containing the following:

[0] - The sklearn classifier object. Any scaler to be applied should be attached as self.scaler.

[1] - A list of class names in the order produced by the classifier.

[2] - A string containing the name of the model class.

[3] - A list of CellProfiler feature names used to generate the model. Must be in the 
same order as the training set.

Use joblib.dump to export this tuple to a file.
""",
        )

        self.want_class_object_sets = Binary(
            "Create object sets from classes?",
            value=False,
            doc="""\
                Choose whether to generate object sets from classes identified by the classifier model
                """
        )

        ############### Classifier class settings ##################
        #
        # A list holding groupings for each of the classifier classes
        # to be extracted
        #
        self.desired_classes = []
        #
        # A count of # of measurements
        #
        self.desired_classes_count = HiddenCount(self.desired_classes)
        #
        # Add one single measurement to start off
        #
        self.add_single_class(False)
        #
        # A button to press to get another measurement
        #
        self.add_class_button = DoSomething(
            "", "Add another class", self.add_single_class
        )

    def add_single_class(self, can_delete=True):
        """Add a class for the classifier to save

        can_delete - True to include a "remove" button, False if you're not
                     allowed to remove it.
        """
        group = SettingsGroup()
        if can_delete:
            group.append("divider", Divider(line=True))

        group.append(
            "class_name",
            Choice(
                "Select a class",
                choices=self.get_class_choices(None),
                choices_fn=self.get_class_choices,
                doc="""\
*(Used only when using {BY_MODEL} mode*

Select which of the class from the classifier you'd like to create
an object set from.
Please note the following:

    -  An object is retained if it falls into the selected class.
    -  You can save multiple classes by using the "Add a class"
       button. Each becomes a separate object set.
    -  If you want to merge classes together, try the CombineObjects module.

""",
            ),
        )

        group.append(
            "class_objects_name",
            LabelName(
                "Name the output objects",
                "ClassifiedObjects",
                doc="""\
*(Used only if using a classifier to create classes)*

Select a name for the object set generated by your classifier.
    """,
            ),
        )

        group.can_delete = can_delete

        if can_delete:
            group.remove_settings_button = RemoveSettingButton(
                "", "Remove this class", self.desired_classes, group
            )
        self.desired_classes.append(group)

    def get_class_choices(self, pipeline):
        if self.contrast_choice == BY_MODEL:
            return self.get_bin_labels()
        return ['None']

    def settings(self):
        result = [self.contrast_choice, self.single_measurement_count, self.desired_classes_count]
        result += functools.reduce(
            lambda x, y: x + y,
            [group.pipeline_settings() for group in self.single_measurements],
        )
        if self.desired_classes_count.value:
            result += functools.reduce(
                lambda x, y: x + y,
                [group.pipeline_settings() for group in self.desired_classes],
            )
        result += [
            self.object_name,
            self.first_measurement,
            self.first_threshold_method,
            self.first_threshold,
            self.second_measurement,
            self.second_threshold_method,
            self.second_threshold,
            self.wants_custom_names,
            self.low_low_custom_name,
            self.low_high_custom_name,
            self.high_low_custom_name,
            self.high_high_custom_name,
            self.wants_image,
            self.image_name,
            self.create_class_sets,
            self.model_directory,
            self.model_file_name,
        ]
        return result

    def visible_settings(self):
        result = [self.contrast_choice]
        if self.contrast_choice == BY_TWO_MEASUREMENTS:
            #
            # Visible settings if there are two measurements
            #
            result += [self.object_name]
            for measurement_setting, threshold_method_setting, threshold_setting in (
                (
                    self.first_measurement,
                    self.first_threshold_method,
                    self.first_threshold,
                ),
                (
                    self.second_measurement,
                    self.second_threshold_method,
                    self.second_threshold,
                ),
            ):
                result += [measurement_setting, threshold_method_setting]
                if threshold_method_setting == TM_CUSTOM:
                    result += [threshold_setting]
            result += [self.wants_custom_names]
            if self.wants_custom_names:
                result += [
                    self.low_low_custom_name,
                    self.low_high_custom_name,
                    self.high_low_custom_name,
                    self.high_high_custom_name,
                ]
            result += [self.wants_image]
            if self.wants_image:
                result += [self.image_name]
        elif self.contrast_choice == BY_SINGLE_MEASUREMENT:
            #
            # Visible results per single measurement
            #
            for group in self.single_measurements:
                if group.can_delete:
                    result += [group.divider]
                result += [group.object_name, group.measurement, group.bin_choice]
                if group.bin_choice == BC_EVEN:
                    result += [
                        group.bin_count,
                        group.low_threshold,
                        group.wants_low_bin,
                        group.high_threshold,
                        group.wants_high_bin,
                    ]
                else:
                    result += [
                        group.custom_thresholds,
                        group.wants_low_bin,
                        group.wants_high_bin,
                    ]
                result += [group.wants_custom_names]
                if group.wants_custom_names:
                    result += [group.bin_names]
                result += [group.wants_images]
                if group.wants_images:
                    result += [group.image_name]
                if group.can_delete:
                    result += [group.remove_settings_button]
            result += [self.add_measurement_button]
        else:
            # Classifier model mode
            result += [self.object_name, self.model_directory, self.model_file_name, self.create_class_sets]
            if self.create_class_sets.value:
                for group in self.desired_classes:
                    if group.can_delete:
                        result += [group.divider]
                    result += [group.class_name, group.class_objects_name]
                    if group.can_delete:
                        result += [group.remove_settings_button]
                result += [self.add_class_button]
        return result

    def run(self, workspace):
        """Classify the objects in the image cycle"""
        if self.contrast_choice == BY_SINGLE_MEASUREMENT:
            if self.show_window:
                workspace.display_data.labels = []
                workspace.display_data.bins = []
                workspace.display_data.values = []
            for group in self.single_measurements:
                self.run_single_measurement(group, workspace)
        elif self.contrast_choice == BY_TWO_MEASUREMENTS:
            self.run_two_measurements(workspace)
        elif self.contrast_choice == BY_MODEL:
            self.run_classifier_model(workspace)
        else:
            raise ValueError(
                "Invalid classification method: %s" % self.contrast_choice.value
            )

    def display(self, workspace, figure):
        if self.contrast_choice == BY_TWO_MEASUREMENTS:
            self.display_two_measurements(workspace, figure)
        elif self.contrast_choice == BY_SINGLE_MEASUREMENT:
            self.display_single_measurement(workspace, figure)
        else:
            self.display_classifier_model(workspace, figure)

    def get_feature_name_matrix(self):
        """Get a 2x2 matrix of feature names for two measurements"""
        if self.wants_custom_names:
            return numpy.array(
                [
                    [self.low_low_custom_name.value, self.low_high_custom_name.value],
                    [self.high_low_custom_name.value, self.high_high_custom_name.value],
                ]
            )
        else:
            m1 = self.first_measurement.value
            m2 = self.second_measurement.value
            return numpy.array(
                [
                    ["_".join((m1, a1, m2, a2)) for a2 in ("low", "high")]
                    for a1 in ("low", "high")
                ]
            )

    def run_two_measurements(self, workspace):
        measurements = workspace.measurements
        in_high_class = []
        saved_values = []
        objects = workspace.object_set.get_objects(self.object_name.value)
        has_nan_measurement = numpy.zeros(objects.count, bool)
        for feature, threshold_method, threshold in (
            (self.first_measurement, self.first_threshold_method, self.first_threshold),
            (
                self.second_measurement,
                self.second_threshold_method,
                self.second_threshold,
            ),
        ):
            values = measurements.get_current_measurement(
                self.object_name.value, feature.value
            )
            if len(values) < objects.count:
                values = numpy.hstack(
                    (values, [numpy.NaN] * (objects.count - len(values)))
                )
            saved_values.append(values)
            has_nan_measurement = has_nan_measurement | numpy.isnan(values)
            if threshold_method == TM_CUSTOM:
                t = threshold.value
            elif len(values) == 0:
                t = 0
            elif threshold_method == TM_MEAN:
                t = numpy.mean(values[~numpy.isnan(values)])
            elif threshold_method == TM_MEDIAN:
                t = numpy.median(values[~numpy.isnan(values)])
            else:
                raise ValueError(
                    "Unknown threshold method: %s" % threshold_method.value
                )
            in_high_class.append(values >= t)
        feature_names = self.get_feature_name_matrix()
        num_values = len(values)
        for i in range(2):
            for j in range(2):
                in_class = (
                    (in_high_class[0].astype(int) == i)
                    & (in_high_class[1].astype(int) == j)
                    & (~has_nan_measurement)
                )
                measurements.add_measurement(
                    self.object_name.value,
                    "_".join((M_CATEGORY, feature_names[i, j])),
                    in_class.astype(int),
                )
                num_hits = in_class.sum()
                measurement_name = "_".join(
                    (M_CATEGORY, feature_names[i, j], F_NUM_PER_BIN)
                )
                measurements.add_measurement(IMAGE, measurement_name, num_hits)
                measurement_name = "_".join(
                    (M_CATEGORY, feature_names[i, j], F_PCT_PER_BIN)
                )
                measurements.add_measurement(
                    IMAGE,
                    measurement_name,
                    100.0 * float(num_hits) / num_values if num_values > 0 else 0,
                )

        if self.wants_image:
            class_1, class_2 = in_high_class
            object_codes = class_1.astype(int) + class_2.astype(int) * 2 + 1
            object_codes = numpy.hstack(([0], object_codes))
            object_codes[numpy.hstack((False, numpy.isnan(values)))] = 0
            nobjects = len(class_1)
            mapping = numpy.zeros(nobjects + 1, int)
            mapping[1:] = numpy.arange(1, nobjects + 1)
            labels = object_codes[mapping[objects.segmented]]
            colors = self.get_colors(4)
            image = colors[labels, :3]
            image = Image(image, parent_image=objects.parent_image)
            workspace.image_set.add(self.image_name.value, image)

        if self.show_window:
            workspace.display_data.in_high_class = in_high_class
            workspace.display_data.labels = (objects.segmented,)
            workspace.display_data.saved_values = saved_values

    def display_two_measurements(self, workspace, figure):
        figure.set_subplots((2, 2))
        object_name = self.object_name.value
        for i, feature_name in (
            (0, self.first_measurement.value),
            (1, self.second_measurement.value),
        ):
            saved_values = workspace.display_data.saved_values[i]
            good_saved_values = saved_values[~numpy.isnan(saved_values)]
            if len(good_saved_values) == 0:
                figure.subplot_table(i, 0, [["No %s objects found" % object_name]])
            else:
                axes = figure.subplot(i, 0)
                axes.hist(good_saved_values)
                axes.set_xlabel(feature_name)
                axes.set_ylabel("# of %s" % object_name)
        class_1, class_2 = workspace.display_data.in_high_class
        object_codes = class_1.astype(int) + class_2.astype(int) * 2 + 1
        object_codes = numpy.hstack(([0], object_codes))
        nobjects = len(class_1)
        mapping = numpy.zeros(nobjects + 1, int)
        mapping[1:] = numpy.arange(1, nobjects + 1)
        for i in range(2):
            saved_values = workspace.display_data.saved_values[i]
            mapping[1:][numpy.isnan(saved_values)] = 0
        labels = object_codes[mapping[workspace.display_data.labels]]
        figure.subplot_imshow_labels(0, 1, labels, title=object_name)
        #
        # Draw a 4-bar histogram
        #
        axes = figure.subplot(1, 1)
        values = object_codes[1:]
        axes.hist(values[~numpy.isnan(values)], bins=4, range=(0.5, 4.5))
        axes.set_xticks((1, 2, 3, 4))
        if self.wants_custom_names:
            axes.set_xticklabels(
                (
                    self.low_low_custom_name.value,
                    self.high_low_custom_name.value,
                    self.low_high_custom_name.value,
                    self.high_high_custom_name.value,
                )
            )
        else:
            axes.set_xticklabels(("low\nlow", "high\nlow", "low\nhigh", "high\nhigh"))
        axes.set_ylabel("# of %s" % object_name)
        colors = self.get_colors(len(axes.patches))
        #
        # The patches are the rectangles in the histogram
        #
        for i, patch in enumerate(axes.patches):
            patch.set_facecolor(colors[i + 1, :])

    def run_single_measurement(self, group, workspace):
        """Classify objects based on one measurement"""
        object_name = group.object_name.value
        feature = group.measurement.value
        objects = workspace.object_set.get_objects(object_name)
        measurements = workspace.measurements
        values = measurements.get_current_measurement(object_name, feature)
        #
        # Pad values if too few (defensive programming).
        #
        if len(values) < objects.count:
            values = numpy.hstack((values, [numpy.NaN] * (objects.count - len(values))))
        if group.bin_choice == BC_EVEN:
            low_threshold = group.low_threshold.value
            high_threshold = group.high_threshold.value
            if low_threshold >= high_threshold:
                raise ValueError("Lower Threshold must be less than Upper Threshold")
            bin_count = group.bin_count.value
            thresholds = (
                numpy.arange(bin_count + 1)
                * (high_threshold - low_threshold)
                / float(bin_count)
                + low_threshold
            )
        else:
            thresholds = [
                float(x.strip()) for x in group.custom_thresholds.value.split(",")
            ]
        #
        # Put infinities at either end of the thresholds so we can bin the
        # low and high bins
        #
        thresholds = numpy.hstack(
            (
                [-numpy.inf] if group.wants_low_bin else [],
                thresholds,
                [numpy.inf] if group.wants_high_bin else [],
            )
        )
        #
        # Do a cross-product of objects and threshold comparisons
        #
        ob_idx, th_idx = numpy.mgrid[0 : len(values), 0 : len(thresholds) - 1]
        bin_hits = (values[ob_idx] > thresholds[th_idx]) & (
            values[ob_idx] <= thresholds[th_idx + 1]
        )
        num_values = len(values)
        for bin_idx, feature_name in enumerate(group.bin_feature_names()):
            measurement_name = "_".join((M_CATEGORY, feature_name))
            measurements.add_measurement(
                object_name, measurement_name, bin_hits[:, bin_idx].astype(int)
            )
            measurement_name = "_".join((M_CATEGORY, feature_name, F_NUM_PER_BIN))
            num_hits = bin_hits[:, bin_idx].sum()
            measurements.add_measurement(IMAGE, measurement_name, num_hits)
            measurement_name = "_".join((M_CATEGORY, feature_name, F_PCT_PER_BIN))
            measurements.add_measurement(
                IMAGE,
                measurement_name,
                100.0 * float(num_hits) / num_values if num_values > 0 else 0,
            )
        if group.wants_images or self.show_window:
            colors = self.get_colors(bin_hits.shape[1])
            object_bins = numpy.sum(bin_hits * th_idx, 1) + 1
            object_color = numpy.hstack(([0], object_bins))
            object_color[numpy.hstack((False, numpy.isnan(values)))] = 0
            labels = object_color[objects.segmented]
            if group.wants_images:
                image = colors[labels, :3]
                workspace.image_set.add(
                    group.image_name.value,
                    Image(image, parent_image=objects.parent_image),
                )

            if self.show_window:
                workspace.display_data.bins.append(object_bins[~numpy.isnan(values)])
                workspace.display_data.labels.append(labels)
                workspace.display_data.values.append(values[~numpy.isnan(values)])

    def display_single_measurement(self, workspace, figure):
        """Display an array of single measurements"""
        figure.set_subplots((3, len(self.single_measurements)))
        for i, group in enumerate(self.single_measurements):
            bin_hits = workspace.display_data.bins[i]
            labels = workspace.display_data.labels[i]
            values = workspace.display_data.values[i]
            if len(values) == 0:
                continue
            #
            # A histogram of the values
            #
            axes = figure.subplot(0, i)
            axes.hist(values[~numpy.isnan(values)])
            axes.set_xlabel(group.measurement.value)
            axes.set_ylabel("# of %s" % group.object_name.value)
            #
            # A histogram of the labels yielding the bins
            #
            axes = figure.subplot(1, i)
            axes.hist(
                bin_hits,
                bins=group.number_of_bins(),
                range=(0.5, group.number_of_bins() + 0.5),
            )
            axes.set_xticks(numpy.arange(1, group.number_of_bins() + 1))
            if group.wants_custom_names:
                axes.set_xticklabels(group.bin_names.value.split(","))
            axes.set_xlabel(group.measurement.value)
            axes.set_ylabel("# of %s" % group.object_name.value)
            colors = self.get_colors(len(axes.patches))
            for j, patch in enumerate(axes.patches):
                patch.set_facecolor(colors[j + 1, :])
            #
            # The labels matrix
            #
            figure.subplot_imshow_labels(
                2,
                i,
                labels,
                title=group.object_name.value,
                sharexy=figure.subplot(2, 0),
            )

    def run_classifier_model(self, workspace):
        classifier = self.get_classifier()
        class_labels = self.get_bin_labels()
        if self.get_classifier_type() == 'Rules':
            # Working with CPA rules.
            class_id_dict = dict((j, i) for i, j in enumerate(class_labels, 1))
            probabilities = classifier.score(workspace.measurements)
            if len(probabilities) > 0:
                is_not_nan = numpy.any(~numpy.isnan(probabilities), 1)
                predicted_classes = numpy.argmax(probabilities[is_not_nan], 1).flatten() + 1
            else:
                predicted_classes = []
        else:
            # Working with a CPA sklearn-based model
            class_id_dict = dict(zip(class_labels, classifier.classes_))
            features = self.split_feature_names(self.get_classifier_features(), workspace.object_set.get_object_names())

            feature_vector = numpy.column_stack(
                [
                    workspace.measurements[object_name, feature_name]
                    for object_name, feature_name in features
                ]
            )

            if hasattr(classifier, 'scaler') and classifier.scaler is not None:
                feature_vector = classifier.scaler.transform(feature_vector)
            numpy.nan_to_num(feature_vector, copy=False)
            predicted_classes = classifier.predict(feature_vector)
            probabilities = classifier.predict_proba(feature_vector)

        m = workspace.measurements

        m.add_measurement(
            self.object_name.value, f"{M_CATEGORY}_Class", [class_labels[i - 1] for i in predicted_classes]
        )

        class_counts = []
        for index, label in enumerate(class_labels):
            class_count = numpy.count_nonzero(predicted_classes == class_id_dict[label])
            class_counts.append(class_count)
            m.add_measurement(
                IMAGE, f"{M_CATEGORY}_{FF_COUNT % label}", class_count
            )
            m.add_measurement(
                self.object_name.value, f"{M_CATEGORY}_Probability_{label}", probabilities[:, index]
            )


        src_objects = workspace.get_objects(self.object_name.value)

        if self.create_class_sets.value:
            for group in self.desired_classes:
                target_id = class_id_dict[group.class_name.value]
                hits = predicted_classes == target_id
                indexes = numpy.flatnonzero(hits) + 1

                #
                # Create an array that maps label indexes to their new values
                # All labels to be deleted have a value in this array of zero
                #
                new_object_count = len(indexes)
                max_label = numpy.max(src_objects.segmented)
                label_indexes = numpy.zeros((max_label + 1,), int)
                label_indexes[indexes] = numpy.arange(1, new_object_count + 1)
                #
                # Loop over both the primary and additional objects
                #
                target_labels = src_objects.segmented.copy()
                #
                # Reindex the labels of the old source image
                #
                target_labels[target_labels > max_label] = 0
                target_labels = label_indexes[target_labels]
                #
                # Make a new set of objects - retain the old set's unedited
                # segmentation for the new and generally try to copy stuff
                # from the old to the new.
                #
                target_objects = Objects()
                target_objects.segmented = target_labels
                target_objects.unedited_segmented = src_objects.unedited_segmented
                #
                # Remove the filtered objects from the small_removed_segmented
                # if present. "small_removed_segmented" should really be
                # "filtered_removed_segmented".
                #
                small_removed = src_objects.small_removed_segmented.copy()
                small_removed[(target_labels == 0) & (src_objects.segmented != 0)] = 0
                target_objects.small_removed_segmented = small_removed
                if src_objects.has_parent_image:
                    target_objects.parent_image = src_objects.parent_image
                workspace.object_set.add_objects(target_objects, group.class_objects_name.value)

                self.add_measurements(workspace, self.object_name.value, group.class_objects_name.value)

        if self.show_window:
            workspace.display_data.identities = class_id_dict
            object_labels = src_objects.segmented.copy()
            object_labels = numpy.insert(predicted_classes, 0, 0)[object_labels]

            workspace.display_data.input_objects = src_objects.segmented
            workspace.display_data.labeled_classes = object_labels
            workspace.display_data.class_counts = class_counts

    def display_classifier_model(self, workspace, figure):
        if get_headless():
            return
        import wx
        cmap = figure.return_cmap()

        figure.set_subplots((2, 2))

        input_labels = workspace.display_data.input_objects
        ax = figure.subplot_imshow_labels(
            0, 0, input_labels, self.object_name.value
        )

        class_labels = workspace.display_data.labeled_classes
        figure.subplot_imshow_labels(
            1, 0, class_labels, "Classified Objects", sharexy=ax, colormap=cmap
        )
        class_counts = workspace.display_data.class_counts
        ids_dict = workspace.display_data.identities
        data = list(zip([""] * len(class_counts), ids_dict.values(), ids_dict.keys(), class_counts))
        figure.subplot_table(
            1,
            1,
            data,
            col_labels=("     ", "ID", "Class Name", "Count"),
        )
        # Fetch the grid object and recolour the left column to match the displayed plot cmap
        table = figure.widgets[-1][-1]
        for i in ids_dict.values():
            color = cmap(i)
            col = wx.Colour(color[0] * 255, color[1] * 255, color[2] * 255)
            table.SetCellBackgroundColour(i-1, 0, col)

    def add_measurements(self, workspace, input_object_name, output_object_name):

        ImageSegmentation.add_measurements(self, workspace, output_object_name)

        objects = workspace.object_set.get_objects(output_object_name)

        parent_objects = workspace.object_set.get_objects(input_object_name)

        children_per_parent, parents_of_children = parent_objects.relate_children(
            objects
        )

        workspace.measurements.add_measurement(
            input_object_name,
            FF_CHILDREN_COUNT % output_object_name,
            children_per_parent,
        )

        workspace.measurements.add_measurement(
            output_object_name, FF_PARENT % input_object_name, parents_of_children,
        )

    def get_colors(self, count):
        """Get colors used for two-measurement labels image"""
        import matplotlib.cm as cm

        cmap = cm.get_cmap(get_default_colormap())
        #
        # Trick the colormap into divulging the values used.
        #
        sm = cm.ScalarMappable(cmap=cmap)
        colors = sm.to_rgba(numpy.arange(count) + 1)
        return numpy.vstack((numpy.zeros(colors.shape[1]), colors))

    def load_classifier(self):
        """Load the classifier pickle if not cached

        returns classifier, bin_labels, name and features
        """
        d = self.get_dictionary()
        file_ = self.model_file_name.value
        directory_ = self.model_directory.get_absolute_path()
        path_ = os.path.join(directory_, file_)
        if path_ not in d:
            if not os.path.isfile(path_):
                raise ValidationError(
                    "No such classifier file: %s" % path_, self.model_file_name
                )
            else:
                if not file_.endswith('.txt'):
                    # Probably a model file
                    import joblib
                    d[path_] = joblib.load(path_)
                    if len(d[path_]) < 3:
                        raise IOError("The selected model file doesn't look like a CellProfiler Analyst classifier."
                                      "See the help dialog for more info on model formats.")
                    if d[path_][2] == "FastGentleBoosting":
                        # FGB model files are not sklearn-based, we'll load it as rules instead.
                        rules = cellprofiler.utilities.rules.Rules()
                        rules.load(d[path_][0])
                        d[path_] = (rules,
                                    d[path_][1],
                                    "Rules",
                                    [f"{rule.object_name}_{rule.feature}" for rule in rules.rules])
                else:
                    # Probably a rules list
                    rules = cellprofiler.utilities.rules.Rules()
                    rules.parse(path_)
                    # Construct a classifier-like object
                    d[path_] = (rules,
                                rules.get_classes(),
                                "Rules",
                                [f"{rule.object_name}_{rule.feature}" for rule in rules.rules])
        return d[path_]

    def get_classifier(self):
        return self.load_classifier()[0]

    def get_bin_labels(self):
        return self.load_classifier()[1]

    def get_classifier_type(self):
        return self.load_classifier()[2]

    def get_classifier_features(self):
        return self.load_classifier()[3]

    def prepare_settings(self, setting_values):
        """Do any sort of adjustment to the settings required for the given values

        setting_values - the values for the settings

        This method allows a module to specialize itself according to
        the number of settings and their value. For instance, a module that
        takes a variable number of images or objects can increase or decrease
        the number of relevant settings so they map correctly to the values."""

        single_measurement_count = int(setting_values[1])
        desired_classes_count = int(setting_values[2])
        if single_measurement_count < len(self.single_measurements):
            del self.single_measurements[single_measurement_count:]
        while single_measurement_count > len(self.single_measurements):
            self.add_single_measurement(True)
        while desired_classes_count > len(self.desired_classes):
            self.add_single_class(True)

    def validate_module(self, pipeline):
        if self.contrast_choice == BY_SINGLE_MEASUREMENT:
            for group in self.single_measurements:
                group.validate_group()
        elif self.contrast_choice == BY_MODEL:
            features = self.get_classifier_features()
            available_features = set([f"{col[0]}_{col[1]}" for col in pipeline.get_measurement_columns(self)])
            for feature in features:
                if feature not in available_features:
                    raise ValidationError(
                        f"""The classifier {self.model_file_name}, requires the measurement "{feature}", but that 
measurement is not available at this stage of the pipeline. Consider adding modules to produce the measurement.""",
                        self.model_file_name
                    )
            if self.create_class_sets.value:
                names = set([group.class_objects_name.value for group in self.desired_classes])
                if len(names) != len(self.desired_classes):
                    raise ValidationError(
                        "Classes being extracted as object sets must have unique names",
                        self.desired_classes[0].class_objects_name
                    )

    def upgrade_settings(self, setting_values, variable_revision_number, module_name):
        """Adjust setting values if they came from a previous revision

        setting_values - a sequence of strings representing the settings
                         for the module as stored in the pipeline
        variable_revision_number - the variable revision number of the
                         module at the time the pipeline was saved. Use this
                         to determine how the incoming setting values map
                         to those of the current module version.
        module_name - the name of the module that did the saving. This can be
                      used to import the settings from another module if
                      that module was merged into the current module
        """
        if variable_revision_number == 1:
            # we modified this in the code but didn't want to bump the variable revision number.
            if BY_SINGLE_MEASUREMENT in setting_values[0]:
                contrast_choice = BY_SINGLE_MEASUREMENT
            else:
                contrast_choice = BY_TWO_MEASUREMENTS
            #
            # We inserted wants_low_bin and wants_high_bin in each group
            #
            new_setting_values = [contrast_choice, setting_values[1]]
            setting_values = setting_values[2:]
            for i in range(int(new_setting_values[1])):
                new_setting_values += setting_values[:3]
                #
                # Bin count changed: don't count the outer 2 bins
                #
                new_setting_values += [str(int(setting_values[3]) - 2)]
                new_setting_values += [setting_values[4]] + ["Yes"]
                new_setting_values += [setting_values[5]] + ["Yes"]
                new_setting_values += setting_values[6:11]
                setting_values = setting_values[11:]
            new_setting_values += setting_values
            setting_values = new_setting_values
            variable_revision_number = 2
        if variable_revision_number == 2:
            setting_values.insert(2, "1")
            insert_point=int(setting_values[1])*13+3
            setting_values.insert(insert_point,'None')
            setting_values.insert(insert_point+1,'ClassifiedObjects')
            setting_values += ['No','Default Output Folder|None','None']
            variable_revision_number = 3

        return setting_values, variable_revision_number

    def get_measurement_columns(self, pipeline):
        columns = []
        if self.contrast_choice == BY_SINGLE_MEASUREMENT:
            for group in self.single_measurements:
                columns += [
                    (
                        IMAGE,
                        "_".join((M_CATEGORY, feature_name, F_NUM_PER_BIN)),
                        COLTYPE_INTEGER,
                    )
                    for feature_name in group.bin_feature_names()
                ]
                columns += [
                    (
                        IMAGE,
                        "_".join((M_CATEGORY, feature_name, F_PCT_PER_BIN)),
                        COLTYPE_FLOAT,
                    )
                    for feature_name in group.bin_feature_names()
                ]
                columns += [
                    (
                        group.object_name.value,
                        "_".join((M_CATEGORY, feature_name)),
                        COLTYPE_INTEGER,
                    )
                    for feature_name in group.bin_feature_names()
                ]
        elif self.contrast_choice == BY_TWO_MEASUREMENTS:
            names = self.get_feature_name_matrix()
            columns += [
                (IMAGE, "_".join((M_CATEGORY, name, F_NUM_PER_BIN)), COLTYPE_INTEGER,)
                for name in names.flatten()
            ]
            columns += [
                (IMAGE, "_".join((M_CATEGORY, name, F_PCT_PER_BIN)), COLTYPE_FLOAT,)
                for name in names.flatten()
            ]
            columns += [
                (self.object_name.value, "_".join((M_CATEGORY, name)), COLTYPE_INTEGER,)
                for name in names.flatten()
            ]
        else:
            columns += [
                (IMAGE, f"{M_CATEGORY}_{FF_COUNT % label}", COLTYPE_INTEGER,) for label in self.get_bin_labels()
            ]
            columns += [
                (self.object_name.value, f"{M_CATEGORY}_Class", COLTYPE_VARCHAR,)
            ]
            columns += [
                (self.object_name.value, f"{M_CATEGORY}_Probability_{label}", COLTYPE_FLOAT,)
                for label in self.get_bin_labels()
            ]
            if self.create_class_sets.value:
                for group in self.desired_classes:
                    columns += ImageSegmentation.get_measurement_columns(self, pipeline, group.class_objects_name.value)
                    columns += [(
                                self.object_name.value,
                                FF_CHILDREN_COUNT % group.class_objects_name.value,
                                COLTYPE_INTEGER,
                                ),
                                (group.class_objects_name.value, FF_PARENT % self.object_name.value, COLTYPE_INTEGER,),
                                ]
        return columns

    def get_categories(self, pipeline, object_name):
        """Return the categories of measurements that this module produces

        object_name - return measurements made on this object (or 'Image' for image measurements)
        """
        if self.contrast_choice == BY_MODEL:
            if object_name == IMAGE:
                if self.create_class_sets.value and len(self.desired_classes) > 0:
                    return [M_CATEGORY, C_COUNT]
                return [M_CATEGORY]
            if object_name == self.object_name.value:
                if self.create_class_sets.value and len(self.desired_classes) > 0:
                    return [M_CATEGORY, C_CHILDREN]
                return [M_CATEGORY]
            elif object_name in [group.class_objects_name for group in self.desired_classes]:
                if self.create_class_sets.value:
                    return [C_LOCATION, C_NUMBER, C_PARENT]
                return []
        else:
            if (
                (object_name == IMAGE)
                or (
                    self.contrast_choice == BY_SINGLE_MEASUREMENT
                    and object_name
                    in [group.object_name.value for group in self.single_measurements]
                )
                or (
                    self.contrast_choice == BY_TWO_MEASUREMENTS
                    and object_name == self.object_name
                )
            ):
                return [M_CATEGORY]

        return []

    def get_measurements(self, pipeline, object_name, category):
        """Return the measurements that this module produces

        object_name - return measurements made on this object (or 'Image' for image measurements)
        category - return measurements made in this category
        """
        result = []
        if self.contrast_choice == BY_SINGLE_MEASUREMENT:
            if category != M_CATEGORY:
                return []
            for group in self.single_measurements:
                if group.object_name == object_name:
                    return group.bin_feature_names()
                elif object_name == IMAGE:
                    for image_features in (F_NUM_PER_BIN, F_PCT_PER_BIN):
                        for bin_feature_names in group.bin_feature_names():
                            result += ["_".join((bin_feature_names, image_features))]
                    return result
        elif self.contrast_choice == BY_TWO_MEASUREMENTS:
            if category != M_CATEGORY:
                return []
            if self.object_name == object_name:
                return self.get_feature_name_matrix().flatten().tolist()
            elif object_name == IMAGE:
                for image_features in (F_NUM_PER_BIN, F_PCT_PER_BIN):
                    for bin_feature_names in (
                        self.get_feature_name_matrix().flatten().tolist()
                    ):
                        result += ["_".join((bin_feature_names, image_features))]
                return result
        elif self.contrast_choice == BY_MODEL:
            if object_name == IMAGE:
                if category == C_COUNT and self.create_class_sets.value:
                    result += [group.class_objects_name.value for group in self.desired_classes]
                elif category == M_CATEGORY:
                    result += [FF_COUNT % label for label in self.get_bin_labels()]
            elif object_name == self.object_name.value:
                if category == M_CATEGORY:
                    result += [f"Class"]
                    result += [f"Probability_{label}" for label in self.get_bin_labels()]
                elif category == C_CHILDREN and self.create_class_sets.value:
                    result += [f"{group.class_objects_name.value}_Count" for group in self.desired_classes]
            for group in self.desired_classes:
                if self.create_class_sets.value and object_name == group.class_objects_name.value:
                    if category == C_NUMBER:
                        result += [FTR_OBJECT_NUMBER]
                    elif category == C_LOCATION:
                        result += [FTR_CENTER_X, FTR_CENTER_Y, FTR_CENTER_Z]
                    elif category == C_PARENT:
                        result += [self.object_name.value]
        return result

    def get_dictionary_for_worker(self):
        # Sklearn models can't be serialized, so workers will need to read them from disk.
        return {}

    def split_feature_names(self, features, available_objects):
        # Attempts to split measurement names into object and feature pairs. Tests against a list of available objects.
        features_list = []
        # We want to test the longest keys first, so that "Cells_Edited" is matched before "Cells".
        available_objects = tuple(sorted(available_objects, key=len, reverse=True))
        for feature_name in features:
            obj, feature_name = next(((s, feature_name.split(f"{s}_", 1)[-1]) for s in available_objects if
                                      feature_name.startswith(s)), feature_name.split("_", 1))
            features_list.append((obj, feature_name))
        return features_list
