Skip to content

Commit

Permalink
Merge branch 'main' into dependabot/pip/pandas-2.1.1
Browse files Browse the repository at this point in the history
  • Loading branch information
aevri authored Jan 28, 2024
2 parents 5d7cb04 + 978e8af commit 957744e
Show file tree
Hide file tree
Showing 5 changed files with 191 additions and 18 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ mel.egg-info/
build/
Pipfile
Pipfile.lock
notebooks/
64 changes: 54 additions & 10 deletions mel/cmd/microadd.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ def setup_parser(parser):
default=0,
help="The index of the device to take images from.",
)
parser.add_argument(
"--last-changed",
action="store_true",
help=(
"Use the image specified in the '__last_changed__' file "
"in the mole's directory for comparison, if present."
),
)

# From NHS 'Moles' page:
# http://www.nhs.uk/Conditions/Moles/Pages/Introduction.aspx
Expand Down Expand Up @@ -86,8 +94,32 @@ def load_context_images(path):
return image_list


def pick_comparison_path(path_list, min_compare_age_days):
def pick_comparison_path(
path, path_list, min_compare_age_days, use_last_changed
):
"""Return the most appropriate image path to compare with, or None."""

# Check for the __last_changed__ file if the --last-changed flag is used
if use_last_changed:
last_changed_path = os.path.join(path, "__last_changed__")
if os.path.exists(last_changed_path):
with open(last_changed_path) as file:
last_changed_image = file.read().strip()
if not last_changed_image:
raise ValueError(
"last changed file must not be empty.",
path,
last_changed_image,
)
for p in sorted(path_list):
if p.startswith(last_changed_image):
return p
raise ValueError(
"could not find referenced last changed image.",
path,
last_changed_image,
)

path_dt_list = [
(x, mel.lib.datetime.guess_datetime_from_path(x)) for x in path_list
]
Expand All @@ -109,23 +141,27 @@ def pick_comparison_path(path_list, min_compare_age_days):
return path_dt_list[-1][0] if path_dt_list else None


def get_comparison_image_path(path, min_compare_age_days):
def get_comparison_image_path(path, min_compare_age_days, use_last_changed):
micro_path = os.path.join(path, "__micro__")
if not os.path.exists(micro_path):
return None

# List all the 'jpg' files in the micro dir
# TODO: support more than just '.jpg'
images = [x for x in os.listdir(micro_path) if x.lower().endswith(".jpg")]
path = pick_comparison_path(images, min_compare_age_days)
path = pick_comparison_path(
path, images, min_compare_age_days, use_last_changed
)
if path:
return os.path.join(micro_path, path)
else:
return None


def load_comparison_image(path, min_compare_age_days):
micro_path = get_comparison_image_path(path, min_compare_age_days)
def load_comparison_image(path, min_compare_age_days, use_last_changed):
micro_path = get_comparison_image_path(
path, min_compare_age_days, use_last_changed
)
if micro_path is None:
return None
return micro_path, mel.lib.image.load_image(micro_path)
Expand All @@ -142,16 +178,24 @@ def process_args(args):
for mole_path in args.PATH:
print(mole_path)
display.reset()
process_path(mole_path, args.min_compare_age_days, display, cap)


def process_path(mole_path, min_compare_age_days, display, cap):
process_path(
mole_path,
args.min_compare_age_days,
display,
cap,
args.last_changed,
)


def process_path(
mole_path, min_compare_age_days, display, cap, use_last_changed
):
# Import pygame as late as possible, to avoid displaying its
# startup-text where it is not actually used.
import pygame

comparison_image_data = load_comparison_image(
mole_path, min_compare_age_days
mole_path, min_compare_age_days, use_last_changed
)

if comparison_image_data is not None:
Expand Down
39 changes: 33 additions & 6 deletions mel/cmd/rotomapcompare.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
'j' to rotate left on the left slot.
'k' to rotate right on the left slot.
'm' to toggle displaying mole markers.
'q' to quit.
"""

Expand Down Expand Up @@ -205,6 +207,8 @@ def on_keydown(event):
display.adjust_rotation(2)
elif key == pygame.K_k:
display.adjust_rotation(-2)
elif key == pygame.K_m:
display.toggle_mole_display()

return on_keydown

Expand Down Expand Up @@ -239,6 +243,7 @@ def __init__(self, logger, screen, path_images_tuple, uuid_):
self._logger = logger
self._image_path = None
self._uuid = None
self._draw_moles = False
self._should_draw_crosshairs = True
self._display = screen
self._melroot = mel.lib.fs.find_melroot()
Expand Down Expand Up @@ -321,6 +326,10 @@ def toggle_crosshairs(self):
self._should_draw_crosshairs = not self._should_draw_crosshairs
self._show()

def toggle_mole_display(self):
self._draw_moles = not self._draw_moles
self._show()

def indicate_changed(self, should_indicate_changed=True):
self._should_indicate_changed = should_indicate_changed
self._show()
Expand Down Expand Up @@ -401,12 +410,12 @@ def auto_align_and_show(self):

self._show()

def _path_pos_zoom_rotation(self, index):
def _path_pos_zoom_rotation_moles(self, index):
image_index = self._rotomap_cursors[index]
posinfo = self._rotomaps[index][image_index]
zoom = self._zooms[index]
rotation = self._rotations[index]
return posinfo.path, posinfo.pos, zoom, rotation
return posinfo.path, posinfo.pos, zoom, rotation, posinfo.uuid_points

def _show(self):
image_width = self._display.width // 2
Expand All @@ -421,14 +430,17 @@ def _show(self):

images = [
captioned_mole_image(
*self._path_pos_zoom_rotation(i),
*self._path_pos_zoom_rotation_moles(i),
image_size,
self._should_draw_crosshairs,
border_colour,
self._draw_moles,
)
for i in self._indices
]
self._image_path = self._path_pos_zoom_rotation(self._indices[-1])[0]
self._image_path = self._path_pos_zoom_rotation_moles(
self._indices[-1]
)[0]
montage = mel.lib.image.montage_horizontal(10, *images)
self._display.show_opencv_image(montage)

Expand All @@ -438,12 +450,19 @@ def captioned_mole_image(
pos,
zoom,
rotation_degs,
uuid_points,
size,
should_draw_crosshairs,
border_colour=None,
draw_moles=False,
):
points = None
if draw_moles:
if uuid_points is not None:
points = tuple(tuple(p) for p in uuid_points.values())

image, caption_shape = _cached_captioned_mole_image(
str(path), tuple(pos), zoom, tuple(size), rotation_degs
str(path), tuple(pos), zoom, tuple(size), rotation_degs, points
)

if should_draw_crosshairs:
Expand All @@ -461,9 +480,17 @@ def captioned_mole_image(


@functools.lru_cache()
def _cached_captioned_mole_image(path, pos, zoom, size, rotation_degs):
def _cached_captioned_mole_image(path, pos, zoom, size, rotation_degs, points):
image = mel.lib.image.load_image(path)
image = mel.lib.image.scale_image(image, zoom)
colors = [[255, 0, 0], [255, 128, 128], [255, 0, 0]]
if points is not None:
for x, y in points:
x *= zoom
x = int(x)
y *= zoom
y = int(y)
mel.rotomap.display.draw_mole(image, x, y, colors)
pos = tuple(int(v * zoom) for v in pos)
size = numpy.array(size)
max_size = 2 * max(size)
Expand Down
38 changes: 36 additions & 2 deletions mel/rotomap/automark.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,49 @@
"""Automatically mark moles on rotomap images."""

import copy
from typing import Dict, List, Union

import numpy

import mel.lib.image
import mel.rotomap.detectmoles
import mel.rotomap.moles


def merge_in_radiuses(targets, radii_sources, error_distance, only_merge):
# Each mole should have these fields:
# uuid: str
# x: int
# y: int
# radius: int
#
# Note that in Python 3.11 we can use TypedDict to enforce this,
# and make radius NotRequired.
#
Moles = List[Dict[str, Union[str, int]]]


def merge_in_radiuses(
targets: Moles,
radii_sources: Moles,
error_distance: int,
only_merge: bool,
) -> list:
"""Merge radius values from radius source dictionaries into target
dictionaries based on their positions.
Args:
targets (list): A list of dictionaries representing the target objects.
Each dictionary must have a unique "uuid" key and may contain "x" and "y" keys representing the position of the target.
radii_sources (list): A list of dictionaries representing the radius source objects.
Each dictionary must have a unique "uuid" key and may contain "x" and "y" keys representing the position of the radius source,
as well as a "radius" key representing the radius value.
error_distance (int): The maximum allowed distance for matching target and radius source objects.
only_merge (bool): Indicates whether to only merge the radius values into the target dictionaries or also include unmatched radius source dictionaries in the results.
Returns:
list: A list of dictionaries representing the merged target and radius source objects.
The dictionaries in the list are deep copies of the target dictionaries with the merged radius values.
If only_merge is False, any unmatched radius source dictionaries are also included in the list.
"""
match_uuids, _, added_uuids = match_moles_by_pos(
targets, radii_sources, error_distance
)
Expand Down
67 changes: 67 additions & 0 deletions tests/rotomap/test_automark.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Test suite for mel.rotomap.relate."""

import pytest

import mel.rotomap.automark as automark

TARGETS = [
{"uuid": "1", "x": 0, "y": 0},
{"uuid": "2", "x": 1, "y": 1},
{"uuid": "3", "x": 2, "y": 2}
]

RADII_SOURCES = [
{"uuid": "4", "radius": 7, "x": 0, "y": 0},
{"uuid": "5", "radius": 12, "x": 1, "y": 1},
{"uuid": "6", "radius": 18, "x": 2, "y": 2},
{"uuid": "7", "radius": 24, "x": 3, "y": 3},
]


@pytest.mark.parametrize("only_merge", [True, False])
@pytest.mark.parametrize("error_distance", [0, 1, 2, 3, 4, 5])
def test_merge_in_radiuses_happy(only_merge, error_distance):
"""Test merge_in_radiuses() with happy path.
For simplicity, there are no radius sources that are not matched to a target.
Each target is matched to a radius source, and the radius value is merged.
"""
radii_sources = [x for x in RADII_SOURCES if x["uuid"] != "7"]
result = automark.merge_in_radiuses(TARGETS, radii_sources, error_distance, only_merge)

assert len(result) == 3
assert result[0]["uuid"] == "1"
assert result[0]["radius"] == 7
assert result[1]["uuid"] == "2"
assert result[1]["radius"] == 12
assert result[2]["uuid"] == "3"
assert result[2]["radius"] == 18


@pytest.mark.parametrize("only_merge", [True, False])
@pytest.mark.parametrize("error_distance", [0, 1, 2, 3, 4, 5])
def test_merge_in_radiuses_happy_merge_extra(only_merge, error_distance):
"""Test merge_in_radiuses() with happy path and extra radius sources.
There is one radius source that is not matched to a target.
It is either included or not included in the result, depending on only_merge.
"""
result = automark.merge_in_radiuses(TARGETS, RADII_SOURCES, error_distance, only_merge)

if only_merge:
assert len(result) == 3
else:
assert len(result) == 4
assert result[0]["uuid"] == "1"
assert result[0]["radius"] == 7
assert result[1]["uuid"] == "2"
assert result[1]["radius"] == 12
assert result[2]["uuid"] == "3"
assert result[2]["radius"] == 18
if not only_merge:
assert result[3]["uuid"] == "7"
assert result[3]["radius"] == 24

0 comments on commit 957744e

Please sign in to comment.