Skip to content

Commit 32103bc

Browse files
committed
Change Channel Color
Allows the user to change channel color within Channel Editor Widget Updated channel map after user chooses new channel color Trying out new dynamic RGB channel that uses first two channel rather than hard-coded magenta mode.
1 parent 92bc20e commit 32103bc

9 files changed

Lines changed: 229 additions & 20 deletions

File tree

src/pymapmanager/interface/stackWidgets/annotationListWidget2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -617,4 +617,4 @@ def _contextMenuEvent(self, event):
617617
menu.addAction(colorAction)
618618

619619
# action = _menu.exec_(self.mapToGlobal(event.pos()))
620-
self.menu.popup(QtGui.QCursor.pos())
620+
menu.popup(QtGui.QCursor.pos())

src/pymapmanager/interface/stackWidgets/base/annotationPlotWidget2.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,10 +1280,17 @@ def toggleRadiusLines(self, toggle: bool = None) -> bool:
12801280

12811281
return self.showRadiusLines
12821282

1283-
def togglePivotPoints(self):
1284-
self.showPivotPoints = not self.showPivotPoints
1283+
def togglePivotPoints(self, toggle: bool = None):
1284+
if toggle is None:
1285+
self.showPivotPoints = not self.showPivotPoints
1286+
else:
1287+
self.showPivotPoints = toggle
1288+
12851289
self._pivotPoints.setVisible(self.showPivotPoints)
12861290
return self.showPivotPoints
1291+
1292+
def arePivotPointsShown(self):
1293+
return self.showPivotPoints
12871294

12881295
# def _getScatterColor(self):
12891296
# """

src/pymapmanager/interface/stackWidgets/base/mmWidget2.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class pmmEventType(Enum):
5757
setColorChannel = auto()
5858

5959
setRadius = auto() # abj
60+
updateChannelMetadata = auto()
6061

6162
# added to refresh gui after modifying the core with undo and redo
6263
refreshSpineEvent = auto()
@@ -851,8 +852,9 @@ def getStack(self):
851852
def getMapTimepoint(self) -> Optional[int]:
852853
"""Get map session from the stack.
853854
"""
854-
if self.getStack() is not None:
855-
return self.getStack().timepoint
855+
stack = self.getStack() # time series core object?
856+
if stack is not None:
857+
return stack.timepoint
856858
# return self.getStack().timepoint()
857859

858860
def getClassName(self) -> str:
@@ -969,6 +971,8 @@ def slot_pmmEvent(self, event : pmmEvent):
969971
#abj
970972
elif event.type == pmmEventType.setRadius:
971973
acceptEvent = self.setRadiusEvent(event)
974+
elif event.type == pmmEventType.updateChannelMetadata:
975+
acceptEvent = self.updateChannelMetadataEvent(event)
972976

973977
# abb 20240716
974978
# segment events
@@ -1323,6 +1327,9 @@ def setRadiusEvent(self, event : pmmEvent):
13231327
# logger.warning(f'{self.getClassName()} base class called')
13241328
pass
13251329

1330+
def updateChannelMetadataEvent(self, event: pmmEvent):
1331+
pass
1332+
13261333
def setSegmentColorEvent(self, event : pmmEvent):
13271334
pass
13281335

src/pymapmanager/interface/stackWidgets/base/stacktoolbar.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,14 @@ def _buildUI(self):
265265

266266
#self.setFocus()
267267

268+
def manuallyUpdatePlotBoxes(self, plotName, checked: bool = False):
269+
"""
270+
"""
271+
action = self.actionMenuDict[plotName]
272+
# self.plotMenuChange(action)
273+
action.setChecked(checked)
274+
self.plotMenuChange(action)
275+
268276
def labelBoxUpdate(self):
269277
""" Part of plot menu Change
270278
@@ -307,6 +315,9 @@ def checkAnnotations(self, check: bool = False):
307315
centerLineAction = self.actionMenuDict["Center Line"]
308316
centerLineAction.setChecked(check)
309317

318+
pivotPointsAction = self.actionMenuDict["Pivot Points"]
319+
pivotPointsAction.setChecked(check)
320+
310321
def plotMenuChange(self, action):
311322
""" Emit a plot name after a given action (check box) is clicked
312323

src/pymapmanager/interface/stackWidgets/channelEditor.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11

22
from functools import partial
33
# from tkinter import Image
4+
from PyQt5 import QtGui
45
from qtpy import QtWidgets, QtCore
56
from PIL import Image
67

@@ -106,12 +107,21 @@ def _buildGUI(self):
106107

107108
self.gridLayout.addWidget(activateBox, self.totalChannelsShown, 2)
108109
activateBox.currentTextChanged.connect(partial(self._onActivate, channelKey))
109-
110+
111+
# Color picker
112+
# logger.info(f"checking channelKey {channelKey}")
113+
channelMetaData = self.getStack().getChannelMetadata(channelKey)
114+
initialColor = channelMetaData.color
115+
# logger.info(f"checking initialColor {initialColor}")
116+
colorPicker = ColorPicker(initialColor, channelKey, self.stackWidget)
117+
self.gridLayout.addWidget(colorPicker, self.totalChannelsShown, 3)
118+
119+
# Deleting Channel
110120
if channelKey > 1: # For now have a restriction on deleting first channel
111121
# if 1:
112122
deleteButton = QtWidgets.QPushButton('')
113123
# self.gridLayout.addWidget(deleteButton, channelIdx + 1, 2)
114-
self.gridLayout.addWidget(deleteButton, self.totalChannelsShown, 3)
124+
self.gridLayout.addWidget(deleteButton, self.totalChannelsShown, 4)
115125

116126
# Set a trashcan icon (using standard icon set)
117127
pixmapi = getattr(QtWidgets.QStyle, "SP_TrashIcon")
@@ -518,3 +528,38 @@ def swapDraggableWidget(self, widgetUnderCursor):
518528

519529
def updateChannelName(self, newChannelName: str = ""):
520530
self.stackWidget.updateChannel(newChannelName, channelIdx = self.getChannelIdx())
531+
532+
class ColorPicker(QtWidgets.QWidget):
533+
def __init__(self, initialColor, channelIdx, stackWidget):
534+
super().__init__()
535+
self.setWindowTitle("Color Picker")
536+
537+
self.initialColor = initialColor
538+
self.channelIdx = channelIdx
539+
self.stackWidget = stackWidget
540+
self.button = QtWidgets.QPushButton("", self)
541+
self.button.setStyleSheet(f"background-color: {initialColor}; padding: 10px;")
542+
self.button.clicked.connect(self.open_color_dialog)
543+
544+
layout = QtWidgets.QVBoxLayout()
545+
# layout.addWidget(self.label)
546+
layout.addWidget(self.button)
547+
self.setLayout(layout)
548+
549+
def open_color_dialog(self):
550+
if type(self.initialColor) == str:
551+
self.initialColor = QtGui.QColor(self.initialColor)
552+
color = QtWidgets.QColorDialog.getColor(self.initialColor)
553+
554+
if color.isValid():
555+
# self.label.setText(f"Selected Color: {color.name()}")
556+
logger.info(f"name is {color.name()}")
557+
self.button.setStyleSheet(f"background-color: {color.name()}; padding: 10px;")
558+
self.storeChannelColor(self.channelIdx, color.name())
559+
self.initialColor = color.name() # update color picker to new color
560+
561+
def storeChannelColor(self, channelIdx, newColor):
562+
""" Store channel color into channelMetadata
563+
"""
564+
# stackwidget wil emit signal to update the rest of widgets
565+
self.stackWidget.setChannelProperty(channelIdx, "color", newColor)

src/pymapmanager/interface/stackWidgets/imagePlotWidget2.py

Lines changed: 125 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import os
22
import numpy as np
3+
import re
4+
import matplotlib.colors as mcolors
35

6+
import matplotlib.pyplot as plt
47
from qtpy import QtGui, QtCore, QtWidgets
58
import pyqtgraph as pg
6-
9+
from pyqtgraph.exporters import ImageExporter
10+
from matplotlib.colors import to_rgb
711
from mapmanagercore.imageImporter import acceptedExtensions
812

913
import pymapmanager
@@ -252,7 +256,7 @@ def contextMenuEvent(self, event : QtGui.QContextMenuEvent):
252256

253257
elif action == copyImageAction:
254258
# pass
255-
exporter = pg.exporters.ImageExporter(self.getPlotWidget().plotItem)
259+
exporter = ImageExporter(self.getPlotWidget().plotItem)
256260
qimage = exporter.export(toBytes=True)
257261

258262
app = self._stackWidget.getPyMapManagerApp()
@@ -262,7 +266,7 @@ def contextMenuEvent(self, event : QtGui.QContextMenuEvent):
262266

263267
elif action == exportImageAction:
264268
# Prompt for file location
265-
exporter = pg.exporters.ImageExporter(self.getPlotWidget().plotItem)
269+
exporter = ImageExporter(self.getPlotWidget().plotItem)
266270
_path = self.getPath()
267271
filters = '(*.png)'
268272
savePath, _ = QtWidgets.QFileDialog.getSaveFileName(self,
@@ -500,6 +504,12 @@ def setRadiusEvent(self, event):
500504
sliceNumber = self._currentSlice
501505
self._aLinePlot.slot_setSlice(sliceNumber)
502506

507+
def updateChannelMetadataEvent(self, event):
508+
"""
509+
"""
510+
# refresh image with new image color
511+
self.refreshSlice()
512+
503513
def slot_setSlice(self, sliceNumber, doEmit=True):
504514
if self.slotsBlocked():
505515
return
@@ -546,20 +556,82 @@ def _setColorLut(self, update=False):
546556
if not self._channelIsRGB():
547557
logger.warning("TODO: add color str like ('red', 'green' 'blue')")
548558
colorStr = self._myStack.getChannelColor(self._displayThisChannelIdx) # like 'r',
549-
559+
logger.info(f"colorStr is {colorStr}")
550560
if colorStr == 'red':
551561
cm = pg.colormap.get('Reds_r', source='matplotlib')
552562
elif colorStr == 'green':
553563
cm = pg.colormap.get('Greens_r', source='matplotlib')
554564
elif colorStr == 'blue':
555565
cm = pg.colormap.get('Blues_r', source='matplotlib')
556566
else:
557-
logger.warning(f'did not understand color {colorStr} -->> defaulting to Greens_r')
558-
# cm = pg.colormap.get('Greys_r', source='matplotlib')
559-
cm = pg.colormap.get('Greens_r', source='matplotlib')
567+
568+
# logger.warning(f'did not understand color {colorStr} -->> defaulting to Greens_r')
569+
# # cm = pg.colormap.get('Greens_r', source='matplotlib')
570+
571+
# here colorStr is a hehex code
572+
colorRGB = to_rgb(colorStr)
573+
574+
# Create a list of colors transitioning from dark to bright
575+
cm_qcolors = []
576+
num_steps = 256 # More steps for smoother gradient
577+
578+
import numpy as np
579+
580+
for i in range(num_steps):
581+
value_factor = i / (num_steps - 1)
582+
# Adjust logarithmic scaling to match matplotlib's dynamic range
583+
log_factor = np.log1p(value_factor * 5) / np.log1p(5)
584+
log_factor = log_factor ** 0.7
585+
586+
# Start from a dark version of the color and interpolate to bright
587+
dark_brightness = 0.3
588+
589+
# Find the dominant channel (should be the one with highest value)
590+
max_channel = max(colorRGB)
591+
is_dominant = [c == max_channel for c in colorRGB]
592+
593+
# Calculate base factors for each channel
594+
r_factor = log_factor * 1.2 if is_dominant[0] else log_factor * 0.3
595+
g_factor = log_factor * 1.2 if is_dominant[1] else log_factor * 0.3
596+
b_factor = log_factor * 1.2 if is_dominant[2] else log_factor * 0.3
597+
598+
# Allow transition to white at highest intensities
599+
white_threshold = 0.7 # When to start transitioning to white
600+
if log_factor > white_threshold:
601+
# Calculate how far we are into the white transition
602+
white_amount = (log_factor - white_threshold) / (1 - white_threshold)
603+
# Smoothly interpolate to white
604+
r_factor = r_factor * (1 - white_amount) + white_amount
605+
g_factor = g_factor * (1 - white_amount) + white_amount
606+
b_factor = b_factor * (1 - white_amount) + white_amount
607+
608+
# Ensure we don't exceed valid values
609+
r_factor = min(1.0, r_factor)
610+
g_factor = min(1.0, g_factor)
611+
b_factor = min(1.0, b_factor)
612+
613+
r = int((colorRGB[0] * dark_brightness * 255) + (255 - colorRGB[0] * dark_brightness * 255) * r_factor)
614+
g = int((colorRGB[1] * dark_brightness * 255) + (255 - colorRGB[1] * dark_brightness * 255) * g_factor)
615+
b = int((colorRGB[2] * dark_brightness * 255) + (255 - colorRGB[2] * dark_brightness * 255) * b_factor)
616+
617+
# Keep alpha at full opacity
618+
a = 255
619+
620+
cm_qcolors.append(QtGui.QColor(r, g, b, a))
621+
622+
# Create matching normalized positions
623+
positions = [i / (len(cm_qcolors) - 1) for i in range(len(cm_qcolors))]
624+
625+
# Create pyqtgraph ColorMap
626+
cm = pg.ColorMap(positions, cm_qcolors)
627+
628+
# # Apply to image
629+
# self._myImage.setLookupTable(lut)
630+
560631

561632
self._myImage.setColorMap(cm)
562633

634+
563635
def _setContrast(self):
564636
if self._channelIsRGB():
565637
tmpLevelList = [] # list of [min,max]
@@ -649,6 +721,8 @@ def _setSlice(self, sliceNumber : int, doEmit = True):
649721
upSlices=upDownSlices, downSlices=upDownSlices,
650722
func=np.max)
651723

724+
# logger.info(f"check ch0_image min {ch1_image.min()} max {ch1_image.max()}")
725+
652726
# rgb requires 8-bit images
653727
ch0_image = ch0_image/ch0_image.max() * 2**8
654728
ch1_image = ch1_image/ch1_image.max() * 2**8
@@ -662,10 +736,30 @@ def _setSlice(self, sliceNumber : int, doEmit = True):
662736
_yShape = ch0_image.shape[1]
663737
sliceImage = np.ndarray((_xShape,_yShape,3))
664738

665-
# magenta is blue + red
666-
sliceImage[:,:,0] = ch1_image # red
667-
sliceImage[:,:,1] = ch0_image # green
668-
sliceImage[:,:,2] = ch1_image # blue
739+
# # magenta is blue + red
740+
# sliceImage[:,:,0] = ch1_image # red
741+
# sliceImage[:,:,1] = ch0_image # green
742+
# sliceImage[:,:,2] = ch1_image # blue
743+
744+
# Get first two channel colors
745+
color0 = self._myStack.getChannelColor(1)
746+
color1 = self._myStack.getChannelColor(2)
747+
748+
color0 = self.colorToHex(color0)
749+
color1 = self.colorToHex(color1)
750+
logger.info(f"color0 {color0} color1 {color1}")
751+
752+
# Convert hex color to RGB arrays
753+
rgb0 = to_rgb(color0) # for ch0_image
754+
rgb1 = to_rgb(color1) # for ch1_image
755+
756+
brightness_factor = 2
757+
for i in range(3): # R, G, B
758+
sliceImage[:,:,i] = (
759+
brightness_factor *
760+
(ch0_image * (rgb0[i])) +
761+
(ch1_image * (rgb1[i])) * i
762+
).clip(0, 255).astype(np.uint8)
669763

670764
else:
671765
sliceImage = self._myStack.getMaxProjectSlice(sliceNumber,
@@ -694,6 +788,23 @@ def _setSlice(self, sliceNumber : int, doEmit = True):
694788
logger.info(f' -->> emitEvent signalUpdateSlice() _currentSlice:{self._currentSlice}')
695789
self.emitEvent(_pmmEvent, blockSlots=True)
696790

791+
792+
def colorToHex(self, color_str):
793+
# Normalize input
794+
color_str = color_str.strip().lower()
795+
796+
# Regex for valid hex color: #RGB, #RRGGBB, RGB, or RRGGBB
797+
hex_pattern = r'^#?([0-9a-f]{3}|[0-9a-f]{6})$'
798+
799+
if re.fullmatch(hex_pattern, color_str):
800+
# Add '#' if missing
801+
return '#' + color_str.lstrip('#')
802+
803+
try:
804+
return mcolors.to_hex(color_str)
805+
except ValueError:
806+
raise ValueError(f"Unknown color name or invalid hex: '{color_str}'")
807+
697808
def _emitSetSlice(self, newSlice):
698809
_pmmEvent = pmmEvent(pmmEventType.setSlice, self)
699810
# _pmmEvent.setSliceNumber(self._currentSlice)
@@ -724,11 +835,13 @@ def togglePlot(self, plotName):
724835
if plotName == "Annotations":
725836
self._toggleAllAnnotations = not self._toggleAllAnnotations
726837
toggle = self._toggleAllAnnotations
838+
logger.info(f"toggle {toggle}")
727839
visible = self._aPointPlot.toggleScatterPlot(toggle) # spines
728840
self._aPointPlot.toggleSpineLines(toggle) # spine (lines)
729-
visible2 = self._aLinePlot.toggleScatterPlot(toggle) # center line
841+
visible2 = self._aLinePlot.toggleSegmentPlot(toggle) # center line
730842
visible3 = self._aLinePlot.toggleRadiusLines(toggle) # radius lines
731843
visible4 = self._aPointPlot.toggleLabels(toggle) # labels
844+
visible5 = self._aLinePlot.togglePivotPoints(toggle)
732845
# pass
733846
elif plotName == "Spines":
734847
visible = self._aPointPlot.toggleScatterPlot()

0 commit comments

Comments
 (0)