Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

186 code feature add backwards pointers to spotanalysisoperables to previous operables #187

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions opencsp/common/lib/cv/spot_analysis/SpotAnalysisOperable.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import numpy.typing as npt
import os
import sys
from typing import TYPE_CHECKING, Optional, Union

import opencsp.common.lib.csp.LightSource as ls
import opencsp.common.lib.cv.annotations.AbstractAnnotations as aa
Expand All @@ -14,6 +15,13 @@
import opencsp.common.lib.tool.file_tools as ft
import opencsp.common.lib.tool.log_tools as lt

if TYPE_CHECKING:
# Use the TYPE_CHECKING magic value to avoid cyclic imports at runtime.
# This import is only here for type annotations.
from opencsp.common.lib.cv.spot_analysis.image_processor.AbstractSpotAnalysisImageProcessor import (
AbstractSpotAnalysisImagesProcessor,
)


@dataclass(frozen=True)
class SpotAnalysisOperable:
Expand All @@ -33,8 +41,20 @@ class SpotAnalysisOperable:
This should be used as the secondary source of this information, after
:py:meth:`best_primary_pathnameext` and :py:attr:`primary_image`.source_path. """
supporting_images: dict[ImageType, CacheableImage] = field(default_factory=dict)
""" The supporting images, if any, that were provided with the
associated input primary image. """
"""
The supporting images, if any, that were provided with the associated input
primary image. These images will be used as part of the computation.
"""
previous_operables: (
tuple[list['SpotAnalysisOperable'], "AbstractSpotAnalysisImagesProcessor"] | tuple[None, None]
) = (None, None)
"""
The operable(s) that were used to generate this operable, and the image
processor that they came from, if any. If this operable has no previous
operables registered with it, then this will have the value (None, None).
Does not include no-nothing image processors such as
:py:class:`.EchoImageProcessor`.
"""
given_fiducials: list[af.AbstractFiducials] = field(default_factory=list)
""" Any fiducials handed to us in the currently processing image. """
found_fiducials: list[af.AbstractFiducials] = field(default_factory=list)
Expand Down Expand Up @@ -102,6 +122,7 @@ def __post_init__(self):
primary_image,
primary_image_source_path,
supporting_images,
self.previous_operables,
self.given_fiducials,
self.found_fiducials,
self.annotations,
Expand All @@ -114,7 +135,8 @@ def __post_init__(self):
def get_all_images(self, primary=True, supporting=True, visualization=True, algorithm=True) -> list[CacheableImage]:
"""
Get a list of all images tracked by this operable including all primary
images, supporting images, visualization, and algorithm images.
images, supporting images, visualization, and algorithm images. Does not
include images from previous operables.

Parameters
----------
Expand Down Expand Up @@ -264,10 +286,36 @@ def get_fiducials_by_type(
)
return ret

def is_ancestor_of(self, other: "SpotAnalysisOperable") -> bool:
"""
Returns true if this operable is in the other operable's
previous_operables tree. Does not match for equality between this and
the other operable.
"""
if other.previous_operables[0] is None:
return False

for prev in other.previous_operables[0]:
if prev == self:
return True
elif self.is_ancestor_of(prev):
return True

return False

def __sizeof__(self) -> int:
"""
Get the size of this operable in memory including all primary images,
supporting images, and visualization images.
"""
all_images_size = sum([sys.getsizeof(img) for img in self.get_all_images()])
return all_images_size

def __str__(self):
name = self.__class__.__name__
image_shape = self.primary_image.nparray.shape
imgsize = f"{image_shape[1]}w{image_shape[0]}h"
source_path = self.best_primary_pathnameext
nfiducials = len(self.given_fiducials) + len(self.found_fiducials)

return f"<{name},{imgsize=},{source_path=},{nfiducials=}>"
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class AbstractSpotAnalysisImageProcessor(Iterator[SpotAnalysisOperable]):
This is an abstract class. Implementations can be found in the same
directory. To create a new implementation, inherit from one of the existing
implementations or this class. The most basic implementation need only
implement the _execute method::
implement the :py:meth:`_execute` method::

def _execute(self, operable: SpotAnalysisOperable, is_last: bool) -> list[SpotAnalysisOperable]:
raise NotImplementedError()
Expand Down Expand Up @@ -87,10 +87,11 @@ def __init__(self, name: str = None):
"""
self.operables_in_flight: list[SpotAnalysisOperable] = []
"""
For most processors, _execute() will return one resultant operable for
each input operable. For these standard cases, this will contain one
value during the process_operable() method, and will be empty otherwise.

For most processors, :py:meth:`_execute` will return one resultant
operable for each input operable. For these standard cases, this will
contain one value during the :py:meth:`process_operable` method, and
will be empty otherwise.

Sometimes _execute() may return zero results. In this case, this value
will contain all the operables passed to _execute() since the last time
that _execute() returned at least one operable. These "in-flight"
Expand All @@ -99,10 +100,10 @@ def __init__(self, name: str = None):
"""
self.results_on_deck: list[SpotAnalysisOperable] = []
"""
Sometimes _execute() may return multiple results. In this case, we hold
on to the processed operables and return only one of them per iteration
in __next__(). This gaurantees that each image processor in the chain
consumes and produces single images.
Sometimes :py:meth:`_execute` may return multiple results. In this case,
we hold on to the processed operables and return only one of them per
iteration in __next__(). This gaurantees that each image processor in
the chain consumes and produces single images.
"""
self._on_image_processed: list[Callable[[SpotAnalysisOperable]]] = []
# A list of callbacks to be evaluated when an image is finished processing.
Expand Down Expand Up @@ -328,6 +329,17 @@ def process_operable(
for callback in self._on_image_processed:
callback(operable)

# register the new operable's "previous_operable" value based on operables_in_flight
for i in range(len(ret)):
# Don't register the returned operable as its own previous operable.
# This shouldn't happen with image processors that follow the
# convention of producing a new operable as their output, but it is
# possible.
# Do nothing image processors can also return the same input
# operable as output, such as for the EchoImageProcessor.
if ret[i] not in self.operables_in_flight:
ret[i] = dataclasses.replace(ret[i], previous_operables=(copy.copy(self.operables_in_flight), self))

# de-register any operable on which we're waiting for results
if len(ret) > 0 or is_last:
self.operables_in_flight.clear()
Expand Down Expand Up @@ -380,7 +392,16 @@ def process_images(self, images: list[CacheableImage | np.ndarray | Image.Image]
def _execute(self, operable: SpotAnalysisOperable, is_last: bool) -> list[SpotAnalysisOperable]:
"""Evaluate an input primary image (and other images/data), and generate the output processed image(s) and data.

The actual image processing method. Called from process_operable().
This is the actual image processing method implemented by each image
processing class. It is called from :py:meth:`process_operable`.

In most cases, the resulting operable(s) should be different from the
input operable(s). This keeps operables (mostly) immutable and makes
following the chain of logic easier for debugging an image processing
pipeline. The exception is for do-nothing image processors, such as for
the EchoImageProcessor. Note that image processors that return the same
input operable as an output won't be added to the operable's
:py:attr:`.previous_operables` history.

Parameters
----------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

class EchoImageProcessor(AbstractSpotAnalysisImageProcessor):
"""
Prints the image names to the console as they are encountered.
A do-nothing processor that prints the image names to the console as they are encountered.
"""

def __init__(self, log_level=lt.log.INFO, prefix=""):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

class ExposureDetectionImageProcessor(AbstractSpotAnalysisImageProcessor):
"""
Detects over and under exposure in images and adds the relavent tag to the image.
A do-nothing processor that detects over and under exposure in images and adds the relavent tag to the image.

Over or under exposure is determined by the proportion of pixels that are at near the max_pixel_value threshold.
If more pixels than the over exposure limit is at the maximum level, then the image is considered over exposed. If
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,33 @@ def test_process_images(self):
)
raise

def test_set_previous_operables(self):
"""Verify that image processors append themselves to the operable's history."""
operable_1 = self.example_operable
processor_1 = SetOnesImageProcessor()
operable_2 = processor_1.process_operable(operable_1, is_last=True)[0]
processor_2 = SetOnesImageProcessor()
operable_3 = processor_2.process_operable(operable_2, is_last=True)[0]

# verify we got different operables as return values
self.assertNotEqual(operable_1, operable_2)
self.assertNotEqual(operable_1, operable_3)
self.assertNotEqual(operable_2, operable_3)

# verify each operable's history
self.assertEqual(operable_1.previous_operables, (None, None))
self.assertEqual(operable_2.previous_operables[0], [operable_1])
self.assertEqual(operable_2.previous_operables[1], processor_1)
self.assertEqual(operable_3.previous_operables[0], [operable_2])
self.assertEqual(operable_3.previous_operables[1], processor_2)

# sanity check - do nothing processors don't add themselves to the history
processor_3 = DoNothingImageProcessor()
operable_4 = processor_3.process_operable(operable_3, is_last=True)[0]
self.assertEqual(operable_3, operable_4)
self.assertEqual(operable_4.previous_operables[0], [operable_2])
self.assertEqual(operable_4.previous_operables[1], processor_2)


if __name__ == '__main__':
unittest.main()