mne-tools / mne-qt-browser

A new backend for the 2D data browser in MNE-Python
BSD 3-Clause "New" or "Revised" License
42 stars 22 forks source link

ENH: framework for drawing channel-specific annotations interactively #204

Open scott-huberty opened 1 year ago

scott-huberty commented 1 year ago

Now that #202 is merged, I wanted to leave a summary here of our game-plan for interactively drawing channel-specific annotations. (this was discussed with @larsoner and @drammock during the September 2023 intermediate code sprint).

CC @nmarkowitz who I believe is also interested in working on this.

To draw a channel specific annotation interactively

  1. Enter annotation mode, and draw a (channel-agnostic) annotation as one normally would
  2. (After drawing the annotation, it is automatically the selected annotation). Now click on a channel name to convert the annotation from channel agnostic to channel specific, and to associate the clicked channel with the annotation.
  3. Click on more channels to associate them with the annotation.

Ground work that needs to be done.

larsoner commented 1 year ago

... and then we have to figure out how to disallow changing channel removal/addition when the annotation is off-screen time-wise. I think the simple solution is that a channel can be added/removed from the selected annotation only if some part of the the selected annotation is in the visible time span.

mscheltienne commented 1 year ago

And could we also add that the selected annotation can be changed into a channel-wise annotation and/or additional channels can be added to the currently selected channel-wise annotation by clicking on the channel trace? Thus disabling entirely the bad/not bad interaction when in annotation mode.

scott-huberty commented 1 year ago

Updated!

nmarkowitz commented 1 year ago

Here's some code that could be useful. It works by interacting with the ChannelAxis (the y-axis listing channel names) and doing shift+left-click to toggle the channel being part of the active annotation. It adds to the already existing mouseClickEvent for it. Basically, if in annotation mode, it gets the name of the channel pressed, gets the annotation index currently active, and then adds/removes that channel to the list of channels associated with that annotation. The added function for this is in the "####" section. I think pieces of it can be used for this next step.

def mouseClickEvent(self, event):
        """Customize mouse click events for ChannelAxis"""
        # Clean up channel-texts
        if not self.mne.butterfly:
            self.ch_texts = {k: v for k, v in self.ch_texts.items()
                             if k in [tr.ch_name for tr in self.mne.traces]}
            # Get channel-name from position of channel-description
            ypos = event.scenePos().y()
            y_values = np.asarray(list(self.ch_texts.values()))[:, 1, :]
            y_diff = np.abs(y_values - ypos)
            ch_idx = int(np.argmin(y_diff, axis=0)[0])
            ch_name = list(self.ch_texts)[ch_idx]
            trace = [tr for tr in self.mne.traces
                     if tr.ch_name == ch_name][0]

    ########################################################
            # If shift+left-click in annotation mode then add to the annotation
            if event.button() == Qt.LeftButton and bool(Qt.ShiftModifier) and self.mne.annotation_mode:
                # Find what the currently active annotation is: self.mne.current_description
                # Access the instance of the annotation
                current_annotation_idx = [annot_ii for annot_ii in range(len(self.mne.inst.annotations))
                                          if self.mne.inst.annotations[annot_ii]['description'] == self.mne.current_description][0]

                ch_list_in_annot = list(self.mne.inst.annotations.ch_names[current_annotation_idx])
                if ch_name not in ch_list_in_annot:
                    self.mne.inst.annotations.ch_names[current_annotation_idx] = tuple( ch_list_in_annot + [ch_name] )
                else:
                    # Remove ch_name from annotation
                    ch_list_in_annot.pop(ch_list_in_annot.index(ch_name))
                    self.mne.inst.annotations.ch_names[current_annotation_idx] = tuple(ch_list_in_annot)
    ########################################################

            elif event.button() == Qt.LeftButton:
                trace.toggle_bad()
            elif event.button() == Qt.RightButton:
                self.weakmain()._create_ch_context_fig(trace.range_idx)
scott-huberty commented 1 year ago

Thx @nmarkowitz !

I probably won't have time this month to get to this so feel free to start a PR if you beat me to it.

current_annotation_idx = [annot_ii for annot_ii in range(len(self.mne.inst.annotations)) if self.mne.inst.annotations[annot_ii]['description'] == self.mne.current_description][0]

FYI I don't think this will work. If there is more than 1 annotation with the same description, this will always return the index of the first annotation that matches the description. I think we'll need a more robust way to find the current annotation. (EDIT) I can probably make a suggestion but I'd need to dig into the code a bit 😄