Skip to content

Commit

Permalink
jsPsych subject feedback (#62)
Browse files Browse the repository at this point in the history
* Initial jsPsych subject feedback implementation
* Uses jsPsych if-condition nodes and loop-condition nodes
* Documentation for subject feedback
* Add core jsPsych library files
  • Loading branch information
gdoubleyew authored Sep 9, 2021
1 parent 918e4d6 commit 22ef95e
Show file tree
Hide file tree
Showing 14 changed files with 3,825 additions and 19 deletions.
1 change: 1 addition & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ A subjectService is started on the presentation computer. The subjecService list
- [Running a Realtime Experiment](docs/how-to-run.md)
- [Run Project in a Docker Container](docs/run-in-docker.md)
- [Using BIDS Data Format in RT-Cloud](docs/using-bids-data.md)
- [Providing Subject Feedback](docs/subject-feedback.md)


## Installation
Expand Down
19 changes: 19 additions & 0 deletions docs/subject-feedback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# **Providing Feedback to Subjects**
Ideally we would like to provide feedback to the subject in the MRI scanner via a web interface. This would allow the researcher to open a web browser and move the browser onto a monitor visible by the subject in the scanner. One convenient toolbox for doing this is jsPsych. We have integrated jsPsych into our project and provide a demo using the DecNef style colored circle feedback.

## **Using jsPsych**
The source code components of jsPsych live in the `web/` directory. File `web/jsPsychFeedback.html` is the main file that will be edited to adjust the type of feedback displayed. The creating a new `draw` method different types of feedback can be created.

### **Running the Demo**<br>
1. The projectServer must be started with --remoteSubject options enabled to allow the feedback webpage to connect and receive results from the projectServer.
- <code>conda activate rtcloud</code>
- <code>bash ./scripts/run-projectInterface.sh --test -p sample --subjectRemote</code>

2. Connect a web browser to the main page
- http://localhost:8888/
- Enter 'test' for both the usnername and password since we are running it in unsecure test mode.

3. Connect a web browser to the jsPsych feedback page
- http://localhost:8888/jspsych

4. Click the 'Run' button on the main page and view the subject feedback shown on the jsPsych page
19 changes: 18 additions & 1 deletion projects/sample/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
# import important modules
import os
import sys
import time
import argparse
import warnings
import numpy as np
Expand Down Expand Up @@ -91,6 +92,8 @@ def doRuns(cfg, dataInterface, subjInterface, webInterface):
OUTPUT:
None.
"""
subjInterface.setMessage("Preparing Run ...")
time.sleep(1)

# variables we'll use throughout
scanNum = cfg.scanNum[0]
Expand Down Expand Up @@ -296,7 +299,11 @@ def doRuns(cfg, dataInterface, subjInterface, webInterface):
# the presentation computer.
if verbose:
print("| send result to the presentation computer for provide subject feedback")
subjInterface.setResult(runNum, int(this_TR), float(avg_niftiData))
# convert to value between 0 and 1
minAvg = 305
maxAvg = 315
feedback = (avg_niftiData - minAvg) / (maxAvg - minAvg)
subjInterface.setResult(runNum, int(this_TR), float(feedback), 1000)

# Finally we will use use webInterface.plotDataPoint() to send the result
# to the web browser to be plotted in the --Data Plots-- tab.
Expand All @@ -310,12 +317,22 @@ def doRuns(cfg, dataInterface, subjInterface, webInterface):

# save the activations value info into a vector that can be saved later
all_avg_activations[this_TR] = avg_niftiData
time.sleep(1)

# create the full path filename of where we want to save the activation values vector.
# we're going to save things as .txt and .mat files
output_textFilename = '/tmp/cloud_directory/tmp/avg_activations.txt'
output_matFilename = os.path.join('/tmp/cloud_directory/tmp/avg_activations.mat')

time.sleep(1)
subjInterface.setMessage("End Run")
responses = subjInterface.getAllResponses()
keypresses = [response.get('key_pressed') for response in responses]
stimDurations = [response.get('stimulus_duration') for response in responses]
if verbose:
print(f'Keypresses: {keypresses}')
print(f'Durations: {stimDurations}')

# use 'putFile' from the dataInterface to save the .txt file
# INPUT:
# [1] filename (full path!)
Expand Down
4 changes: 2 additions & 2 deletions rtCommon/dataInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import re
import time
import glob
import chardet
import threading
import logging
from pathlib import Path
Expand Down Expand Up @@ -461,4 +460,5 @@ def downloadFilesFromCloud(dataInterface, srcFilePattern :str, outputDir :str, d
# 'downloadFolderFromCloud',
# 'downloadFilesFromCloud',
# ]
# self.addLocalAttributes(localOnlyFunctions)
# self.addLocalAttributes(localOnlyFunctions)

47 changes: 40 additions & 7 deletions rtCommon/subjectInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import time
from queue import Queue, Empty
from rtCommon.remoteable import RemoteableExtensible
from rtCommon.errors import ValidationError


class SubjectInterface(RemoteableExtensible):
Expand Down Expand Up @@ -41,35 +42,67 @@ def __init__(self, subjectRemote=False):
if subjectRemote is True:
return
self.msgQueue = Queue()
self.message = ""

def setResult(self, runId :int, trId :int, value: float) -> None:
# NOTE: The below function implementations will only be used when there is no
# external subjectInterface connected to the projectServer. Thus these
# implementations are just for testing or as a place holder when no external
# subjectInterface is used.

def setResult(self, runId :int, trId :int, value: float, onsetTimeDelayMs: int=0) -> None:
"""
Whe setResult is called by the experiment script it queues the result for
When setResult is called by the experiment script it queues the result for
the presentation script to later read and use to provide subject feedback.
Args:
runId: experiment specific identifier of the run
trId: volume number of the dicom within a run
value: the classification result from processing the dicom image for this TR
onsetTimeDelayMs: time in milliseconds to wait before presenting the feedback stimulus
"""
print(f'SubjectInterface: setResult: run {runId}, tr {trId}, value {value}')
if onsetTimeDelayMs < 0:
raise ValidationError(f'onsetTimeDelayMs must be >= 0, {onsetTimeDelayMs}')

feedbackMsg = {
'runId': runId,
'trId': trId,
'value': value,
'onsetTimeDelayMs': onsetTimeDelayMs,
'timestamp': time.time()
}
self.msgQueue.put(feedbackMsg)

def dequeueResult(self, block :bool=False, timeout :int=None) -> float:
def setMessage(self, message: str) -> None:
"""
Return the next result value send by the experiment script. Used by the
presentation script.
Updates the message displayed to the subject
"""
return self.msgQueue.get(block=block, timeout=timeout)
print(f'SubjectInterface: setMessage: {message}')
self.message = message

def getResponse(self, runId :int, trId :int):
"""
Retrieve the subject response, used by the classification script.
See *note* above - these local versions of the function are just
for testing or as a place holder when no external subjectInterface
is used.
"""
print(f'SubjectInterface: getResponse: run {runId}, tr {trId}')
pass
return {}

def getAllResponses(self):
"""
Retrieve all subject responses since the last time this call was made
"""
print(f'SubjectInterface: getAllResponses')
return [{}]

def dequeueResult(self, block :bool=False, timeout :int=None) -> float:
"""
Return the next result value sent by the experiment script. Used by the
presentation script.
See *note* above - these local versions of the function are just
for testing or as a place holder when no external version is used.
"""
return self.msgQueue.get(block=block, timeout=timeout)


24 changes: 16 additions & 8 deletions rtCommon/webServer.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from rtCommon.certsUtils import getCertPath, getKeyPath
from rtCommon.structDict import StructDict, recurseCreateStructDict
from rtCommon.webHttpHandlers import HttpHandler, LoginHandler, LogoutHandler, certsDir
from rtCommon.webSocketHandlers import sendWebSocketMessage, BaseWebSocketHandler
from rtCommon.webSocketHandlers import BaseWebSocketHandler
from rtCommon.webDisplayInterface import WebDisplayInterface
from rtCommon.projectServerRPC import ProjectRPCService
from rtCommon.dataInterface import uploadFilesFromList
Expand Down Expand Up @@ -62,10 +62,13 @@ def start(params, cfg, testMode=False):
params.confDir = os.path.join(Web.webDir, 'conf/')
if params.port:
Web.httpPort = params.port
if not os.path.exists(certsDir):
os.makedirs(certsDir)
src_root = os.path.join(Web.webDir, 'src')
css_root = os.path.join(Web.webDir, 'css')
img_root = os.path.join(Web.webDir, 'img')
build_root = os.path.join(Web.webDir, 'build')
jsPsych_root = os.path.join(Web.webDir, 'jsPsych')
cookieSecret = getCookieSecret(certsDir)
settings = {
"cookie_secret": cookieSecret,
Expand Down Expand Up @@ -101,6 +104,7 @@ def start(params, cfg, testMode=False):
Web.app = tornado.web.Application([
(r'/', HttpHandler, dict(htmlDir=Web.htmlDir, page='index.html')),
(r'/feedback', HttpHandler, dict(htmlDir=Web.htmlDir, page='biofeedback.html')), # shows image
(r'/jspsych', HttpHandler, dict(htmlDir=Web.htmlDir, page='jsPsychFeedback.html')),
(r'/login', LoginHandler, dict(htmlDir=Web.htmlDir, page='login.html', testMode=Web.testMode)),
(r'/logout', LogoutHandler),
(r'/wsUser', BaseWebSocketHandler, dict(name='wsUser', callback=Web.browserRequestHandler._wsBrowserCallback)),
Expand All @@ -109,6 +113,10 @@ def start(params, cfg, testMode=False):
(r'/css/(.*)', tornado.web.StaticFileHandler, {'path': css_root}),
(r'/img/(.*)', tornado.web.StaticFileHandler, {'path': img_root}),
(r'/build/(.*)', tornado.web.StaticFileHandler, {'path': build_root}),
(r'/jspsych/(.*)', tornado.web.StaticFileHandler, {'path': jsPsych_root}),
# /wsSubject gets added in projectServer.py when remoteSubject is True
# /wsData gets added in projectServer.py when remoteData is True
# (r'/wsSubject', BaseWebSocketHandler, dict(name='wsSubject', callback=defaultWebsocketCallback)),
], **settings)
Web.httpServer = tornado.httpserver.HTTPServer(Web.app, ssl_options=ssl_ctx)
Web.httpServer.listen(Web.httpPort)
Expand All @@ -124,15 +132,15 @@ def stop():
"""Stop the web server."""
Web.ioLoopInst.add_callback(Web.ioLoopInst.stop)
Web.app = None

# Possibly use raise exception to stop a thread
# def raise_exception(self): i.e. for stop()
# thread_id = self.get_id()
# res = ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id,
# ctypes.py_object(SystemExit))
# if res > 1:
# ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, 0)
# print('Exception raise failure')
# thread_id = self.get_id()
# res = ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id,
# ctypes.py_object(SystemExit))
# if res > 1:
# ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, 0)
# print('Exception raise failure')

@staticmethod
def close():
Expand Down
13 changes: 12 additions & 1 deletion tests/test_subjectInterface.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pytest
from tests.backgroundTestServers import BackgroundTestServers
from rtCommon.clientInterface import ClientInterface
from rtCommon.errors import ValidationError


class TestSubjectInterface:
Expand Down Expand Up @@ -37,10 +38,20 @@ def runSubjectFeedbackTest(isRemote):
runId = 3
for trId in range(1, 10):
value = 20 + trId
subjInterface.setResult(runId, trId, value)
onsetTimeDelayMs = trId + 4
subjInterface.setResult(runId, trId, value, onsetTimeDelayMs)

for i in range(1, 10):
feedbackMsg = subjInterface.dequeueResult(block=False, timeout=1)
assert feedbackMsg['runId'] == runId
assert feedbackMsg['trId'] == i
assert feedbackMsg['value'] == 20 + i
assert feedbackMsg['onsetTimeDelayMs'] == i + 4

subjInterface.setResult(1, 2, 3, 3.1)
feedbackMsg = subjInterface.dequeueResult(block=False, timeout=1)
assert feedbackMsg['onsetTimeDelayMs'] == 3.1

with pytest.raises((ValidationError, Exception)):
# Try setting a negative onsetTimeDelay
subjInterface.setResult(1, 2, 3, -1)
Loading

0 comments on commit 22ef95e

Please sign in to comment.