diff --git a/README.md b/README.md index 66fd75c..bf46a95 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ You need to install the following system-wide: * Python 2.7 * [Qt4](http://doc.qt.io/qt-4.8/installation.html) and [PyQt4](http://pyqt.sourceforge.net/Docs/PyQt4/installation.html) -* [OpenCV](http://docs.opencv.org/doc/tutorials/introduction/table_of_content_introduction/table_of_content_introduction.html) +* [OpenCV 2 or 3](http://docs.opencv.org/doc/tutorials/introduction/table_of_content_introduction/table_of_content_introduction.html) * [OpenCV-Python](https://opencv-python-tutroals.readthedocs.org/en/latest/py_tutorials/py_setup/py_table_of_contents_setup/py_table_of_contents_setup.html#py-table-of-content-setup) ## Debian Dependencies diff --git a/arg.py b/arg.py new file mode 100644 index 0000000..507a179 --- /dev/null +++ b/arg.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +from argparse import ArgumentParser + +def get_arguments(): + parser = ArgumentParser(description = 'Slouchy uses your webcam t' + 'o determine if you are slouching and alerts you when you' + 'are. This project is still in active development and not' + 'feature complete.') + + # Flags + ui_mode = parser.add_mutually_exclusive_group() + ui_mode.add_argument('--text-mode', '-t', action = 'store_true', + help = 'Put slouchy in text mode, disabling all GUI features.') + ui_mode.add_argument('--gui', '-g', action = 'store_true', + help = 'Put slouchy in GUI mode (the default). GUI mode normally') + + # Settings overrides + parser.add_argument('--config-file', '-s', type = str, + help = 'The location of a config file for slouchy.') + parser.add_argument('--video-device', '-d', type = str, + help = 'Value for specifying the camera to use.') + parser.add_argument('--poll-rate', '-p', type = int, + help = 'Time to wait between checking user posture.') + parser.add_argument('--camera-warm-up', '-w', type = int, + help = 'Time needed for the user camera to initialize.') + parser.add_argument('--distance-reference', '-r', type = float, + help = 'The face-to-camera distance value of the subject ' + 'when sitting upright. Due to the geometrical limi' + 'ts spinal mobility, this is used as a proxy for d' + 'etecting slouching.') + parser.add_argument('--thoracolumbar-tolerance', '-l', type = float, + help = 'The amount of deviation from the reference which' + ' will be tolerated before registering the lower a' + 'nd mid back of the subject is slouching.') + parser.add_argument('--cervical-tolerance', '-c', type = float, + help = 'The amount lateral flexion of the cervical before' + ' registering poor neck posture.') + parser.add_argument('--face-cascade-path', '-f', type = str, + help = 'The path of a face cascade clasifier.') + parser.add_argument('--eye-cascade-path', '-e', type = str, + help = 'The path of an eye cascade clasifier.') + + return parser.parse_args() diff --git a/config.py b/config.py index 0037975..a12fee9 100644 --- a/config.py +++ b/config.py @@ -1,24 +1,42 @@ +# -*- coding: utf-8 -*- from configobj import ConfigObj -# Local imports -from main import video_device, determine_posture, take_picture #, detect_face +# Local import +from arg import get_arguments -# Set initial values -def setup(): - config = ConfigObj('slouchy.ini') +# Get all command-line arguments. arg.get_arguments() returns a Namespace +# object containg True or False values for the interface mode (GUI or CLI) +# and numeric/string values for selectively overiding the slouchy.ini settings +args = get_arguments() - maybe_image = take_picture(video_device) - # maybe_face = detect_face(maybe_image) - maybe_current_posture = determine_posture(maybe_image) +# Determine if the user wants status output on the command line +text_mode = args.text_mode - if maybe_current_posture.success: - config['MAIN']['posture_reference'] = str(maybe_current_posture.result.get('distance')) - print("Reference value detected as:", maybe_current_posture.result) - else: - print("Error:", maybe_current_posture.result) - return maybe_current_posture +# Load settings from the config file (default to slouchy.ini) +if args.config_file: + config_file = ConfigObj(args.config_file) +else: + config_file = ConfigObj('slouchy.ini') - config.write() +# Dict-ize args (for looping) +args = vars(args) -if __name__ == '__main__': - setup() +# Overide config file settings per the command line +for key, val in args.iteritems(): + if key in config_file['MAIN'].keys(): + globals()[key] = args[key] if args[key] else config_file['MAIN'][key] + +# Some settings need to be floats (not strings) +for i in ['distance_reference', 'thoracolumbar_tolerance',\ + 'cervical_tolerance', 'camera_warm_up']: + globals()[i] = float(globals()[i]) + +# poll_rate needs to be an int +globals()['poll_rate'] = int(globals()['poll_rate']) + +# video_device can be either an int or str, so try int but fall back on str +video_device = globals()['video_device'] +try: + video_device = int(video_device) +except ValueError: + video_device = str(video_device) diff --git a/main.py b/main.py index c173a47..854b880 100644 --- a/main.py +++ b/main.py @@ -1,15 +1,39 @@ # -*- coding: utf-8 -*- import cv2 import time +import config from collections import namedtuple -from configobj import ConfigObj from math import atan, sqrt """ Slouchy uses your webcam to determine if you are slouching and alerts you when you are. This project is still in active development and not feature complete. +Example: + $ ./slouchy.py [arguments] + +Arguments (unimplemented): + -t, --text-mode Put slouchy in text mode, disabling all GUI features. + -g, --gui Put slouchy in GUI mode (the default). GUI mode normally + detaches slouchy from the terminal. + -h, --help Print a help message, then terminate. + +Attributes: + config (configobj.ConfigObj): Used to access slouchy's config file. All + other module level variable get their values from there. + distance_reference (float): The distance value for the subject when sitting + upright. + thoracolumbar_tolerance (float): The ammount of deviation from the + reference which will be tolerated before reporting the subject is + slouching. + cervical_tolerance (float): The amount lateral flexion of the cervical + before assuming slouching. Note: this and a few other values will be + integrated into a single model to better discern slouching. + face_cascade_path (str): The path for the face cascade classifier. + eye_cascade_path (str): The path for the eye cascade classifier. + camera_warm_up (int): The Δtime needed for the user camera to initialize. + Modules communicate with named tuples called Maybe. It is designed to emulate the behavior of Maybe/Either constructs in functional languages. @@ -20,33 +44,6 @@ If success is false, result will be a string containing an error message. """ -# Example: -# $ ./slouchy.py [arguments] - -# Arguments (unimplemented): -# -t Put slouchy in text-only mode. All GUI features are disabled. This -# mode also implicitly activates -v (verbose mode). -# -g Put slouchy in GUI mode (the default). GUI mode normally detaches -# slouchy from the terminal. -# -v Put slouchy in verbose mode. It will output all important information -# on the command-line. If in GUI mode, slouchy will remain connected to -# the terminal (providing additional addional information to the GUI). -# If in text-only mode, this option is redundant. -# -h Print a help message, then terminate. - -# Attributes: -# config (configobj.ConfigObj): Used to access slouchy's config file. All -# other module level variable get their values from there. -# posture_reference (float): The distance value for the subject when sitting -# upright. -# allowed_variance (float): The ammount of deviation from the reference -# which will be tolerated before reporting the subject is slouching. -# lat_cerv_tol (float): The amount lateral flexion of the cervical before -# assuming slouching. Note: this and a few other values will be -# integrated into a single model to better discern slouching. -# face_cascade_path (str): The path for the face cascade classifier. -# eye_cascade_path(str): The path for the eye cascade classifier. -# camera_delay (int): The Δtime needed for the user camera to initialize. # Some pseudo-functional programming here: use of namedtuples to simulate the # Maybe/Either construct throughout this program. Success is always True or @@ -55,29 +52,16 @@ # error message Maybe = namedtuple('Maybe', ['success','result']) -# Load settings from slouchy.ini -config = ConfigObj('slouchy.ini') -posture_reference = float(config['MAIN']['posture_reference']) -allowed_variance = float(config['MAIN']['allowed_variance']) -lat_cerv_tol = float(config['MAIN']['lat_cerv_tol']) -face_cascade_path = str(config['MAIN']['face_cascade_path']) -eye_cascade_path = str(config['MAIN']['eye_cascade_path']) -camera_delay = int(config['MAIN']['camera_delay']) - -#video_device can be an int or a string, so try int, and if not assume string -try: - video_device = int(config['MAIN']['video_device']) -except ValueError: - video_device = str(config['MAIN']['video_device']) - -cap = cv2.VideoCapture(video_device) +cap = cv2.VideoCapture(config.video_device) camera_width = float(cap.get(3)) camera_height = float(cap.get(4)) -print("camera_width:", camera_width) -print("camera_height:", camera_height) + +if config.text_mode: + print('Camera field of view: {} high, {} wide' + .format(camera_height, int(camera_width))) cap.release() -# Calculate MaybeFace -> MaybeDistance + def determine_distance(MaybeFace): """ Use height and width information of face to find its distance from the camera. @@ -89,7 +73,7 @@ def determine_distance(MaybeFace): All that matters here are the relationships. Args: - MaybeFace tuple: Containing success status, and results. + MaybeFace tuple: Containing success status, and results. If successful, results contain the x, y, width, and height of the region in the previously taken image determined to depict a face. Returns: @@ -100,12 +84,12 @@ def determine_distance(MaybeFace): else: return MaybeFace - print("x =", '{:d}'.format(x)) - print("y =", '{:d}'.format(y)) - print("w =", '{:d}'.format(w)) - print("h =", '{:d}'.format(h)) + if config.text_mode: + print('Face detected') + print('-------------') + print(' Position: x = {:d}, y = {:d}'.format(x, y)) + print(' Dimensions: w = {:d}, h = {:d}'.format(w, h)) - # distance = (y**2 + w**2)**0.5 distance = sqrt(y**2 + w**2) return Maybe(True, distance) @@ -116,9 +100,11 @@ def get_face_width(MaybeFace): else: return MaybeFace - return Maybe(True, w) + (x, y, w, h) = MaybeFace.result + return Maybe(True, w) -# Take a picture with the camera. + +# Take a picture with the camera. # Ideally this is where we always transition from the impure to "pure" calculations. # video_device -> MaybeImage def take_picture(video_device): @@ -136,15 +122,15 @@ def take_picture(video_device): cap = cv2.VideoCapture(video_device) cap.open(video_device) - if camera_delay > 0: # Some cameras need to be given worm up time - time.sleep(camera_delay) + if config.camera_warm_up > 0: # Some cameras need to be given worm up time + time.sleep(config.camera_warm_up) if not cap.isOpened(): exit('Failed to open camera. Please make sure video_device is set \ correctly.') - ret, image = cap.read() # Grab and decode frame from the camera - cap.release() # Close the camera + ret, image = cap.read() # Grab and decode frame from the camera + cap.release() # Close the camera if not ret: return Maybe(False, 'Camera unexpectedly disconnected.') @@ -154,7 +140,8 @@ def take_picture(video_device): # Make image grayscale for processing gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - return Maybe(True, gray_image) + return Maybe(True, gray_image) + # MaybeImage -> MaybePosture def determine_posture(MaybeImage): @@ -170,7 +157,7 @@ def determine_posture(MaybeImage): face_image = image[y:y+h, x:x+w] # Crop the image. Eyes are only on faces else: return Maybe(False, 'No face detected.') - + maybe_distance = determine_distance(maybe_face) #Get face-camera distance if maybe_distance.success: distance = maybe_distance.result @@ -186,7 +173,6 @@ def determine_posture(MaybeImage): return Maybe(True, {'distance' : distance, 'tilt' : tilt}) -# MaybeImage -> MaybeFace def detect_face(MaybeImage): """ Take an image and return positional information for the largest face in it. @@ -205,7 +191,7 @@ def detect_face(MaybeImage): else: return MaybeImage - faceCascade = cv2.CascadeClassifier(face_cascade_path) # Load face classifier + faceCascade = cv2.CascadeClassifier(config.face_cascade_path) # Load face classifier major_ver, _, _ = (cv2.__version__).split('.') @@ -231,15 +217,13 @@ def detect_face(MaybeImage): return Maybe(False, "No faces detected. This may be due to low or uneven \ lighting.") -# FIX: Seems to only detect head tilting to the right? -# face -> MaybeTilt + def find_head_tilt(face): """Take one facial image and return the angle (only magnitude) of its tilt""" - classifier = cv2.CascadeClassifier(eye_cascade_path) + classifier = cv2.CascadeClassifier(config.eye_cascade_path) if classifier.empty(): return Maybe(False, "Empty classifier") - # return 0 # Don't complain, gracefully continue without this function eyes = classifier.detectMultiScale(face) @@ -247,19 +231,21 @@ def find_head_tilt(face): # lateral angle of the head. If one or none are detected, skip this. If # more are detected, assume any after the first two are false positives. if len(eyes) > 1: - print str(len(eyes)) + ' eyes detected' left = eyes[0] right = eyes[1] print 'Left eye', left, 'Right eye', right slope = (left[1] - right[1]) / (left[0] - right[0]) angle = abs(atan(slope)) + + if config.text_mode: + print('Eyes detected, indicating a lateral inclination of {}' + .format(angle)) + return Maybe(True, angle) return Maybe(False, "No eyes found") - # return 0 # If both eyes couldn't be found, assume a level head -# Detect if person is slouching -# MaybePos -> MaybeSlouching + def detect_slouching(MaybePos): """ Use provide postural information to determine if the subject is slouching. @@ -280,12 +266,12 @@ def detect_slouching(MaybePos): current_posture = posture.get('distance') tilt = posture.get('tilt') - c_min = posture_reference * (1.0 - allowed_variance) - c_max = posture_reference * (1.0 + allowed_variance) + c_min = config.distance_reference * (1.0 - config.thoracolumbar_tolerance) + c_max = config.distance_reference * (1.0 + config.thoracolumbar_tolerance) - print("c_min:", c_min) - print("current_posture:", current_posture) - print("c_max:", c_max) + if config.text_mode: + print(' Measured distance: {}'.format(current_posture)) + print(' Should be within {} and {}'.format(c_min, c_max)) if c_min <= current_posture <= c_max: body_slouching = False @@ -293,7 +279,7 @@ def detect_slouching(MaybePos): body_slouching = True # TODO: Adjust so these two types of slouching alert users with different messages. - if tilt > lat_cerv_tol: + if tilt > config.cervical_tolerance: head_tilting = True else: head_tilting = False @@ -304,11 +290,11 @@ def detect_slouching(MaybePos): # MaybeSlouching def slouching_results(): - maybe_image = take_picture(video_device) - maybe_posture = determine_posture(maybe_image) + maybe_image = take_picture(config.video_device) + maybe_posture = determine_posture(maybe_image) maybe_slouching = detect_slouching(maybe_posture) return maybe_slouching if __name__ == '__main__': - slouching_results() + slouching_results() diff --git a/slouchy.ini b/slouchy.ini index 5a21444..cc7b375 100644 --- a/slouchy.ini +++ b/slouchy.ini @@ -1,26 +1,24 @@ [MAIN] -# posture_reference +# distance_reference # Determined from running Setup, should be a 6 digit number. -posture_reference = 228.449119062 - -# allowed_variance -# Use to adjust the sensitivity of slouch detection -# 0.05 to 0.3 should be sane values. Experiment with what works for you. - -# Tweak until the program correctly detects you slouching/upright +distance_reference = 249.110417285 +# thoracolumar_tolerance (Back) +# Use to adjust the sensitivity of slouch detection. +# Tweak until the program correctly detects you slouching/upright. # You should need small values (0.1 or so) # unless you wildly vary how close you sit to your computer. - +# 0.05 to 0.3 should be sane values. Experiment with what works for you. +# # Note: The closer you get to the camera the more error there is in measurement. -allowed_variance = 0.05 +thoracolumbar_tolerance = 0.05 -# lat_cerv_tol +# cervical_tolerance (Neck) # Set the tolerance for the lateral cervical angle (in radians). # Probably good values: 0.3 (~17 deg), 0.35 (~20 deg), 0.4 (~23 deg) -lat_cerv_tol = 0.3 +cervical_tolerance = 0.3 -# cascade_paths +# face_cascade_path / eye_cascade_path # Path to the haarscascade xml files, feel free to try other face cascades as well. # If you find good results with one let me know. face_cascade_path = haarcascade_frontalface_default.xml @@ -34,16 +32,17 @@ eye_cascade_path = haarcascade_eye.xml # -1 should work for most people video_device = -1 -# check_frequency -# How often the slouchy.py app should check to see if you're slouching (in seconds) -check_frequency = 60 +# poll_rate +# How often slouchy.py should check to see if you're slouching (in seconds) +poll_rate = 60 # camera_delay # Some cameras require time to warm up before taking a picture +# If you're using a Mac you probably want to set this to a few seconds. # If slouchy isn't detecting faces, and you're in a well-lit area # then specify a delay (in seconds) to allow your camera to warm up first. -camera_delay = 0 +camera_warm_up = 0 # alert_duration - NOT IMPLEMENTED YET - # How long the slouchy.py slouching alert should be shown (in seconds) +# How long the slouchy.py slouching alert should be shown (in seconds) # alert_duration = 5 diff --git a/slouchy.py b/slouchy.py index d53ab0d..162f987 100755 --- a/slouchy.py +++ b/slouchy.py @@ -1,5 +1,6 @@ #!/usr/bin/env python2 # -*- coding: utf-8 -*- + import sys import signal import time @@ -7,8 +8,8 @@ from PyQt4 import QtGui, QtCore # Local imports -from config import setup -from main import config +import config +from main import take_picture, determine_posture # This fixes an UnboundLocalError / referenced before assignment error... # Directly importing slouching_results doesn't work? @@ -16,8 +17,24 @@ # Qt4 threading advice from here: https://joplaete.wordpress.com/2010/07/21/threading-with-pyqt4/ -check_frequency = int(config['MAIN']['check_frequency']) -# alert_duration = int(config['MAIN']['alert_duration']) +check_frequency = config.poll_rate + +# Set initial values to slouchy.ini +def setup(): + maybe_image = take_picture(config.video_device) + maybe_current_posture = determine_posture(maybe_image) + + if maybe_current_posture.success: + distance_reference = str(maybe_current_posture.result.get('distance')) + config.config_file['MAIN']['distance_reference'] = distance_reference + print("Reference value detected as:", maybe_current_posture.result) + + else: + print("Error:", maybe_current_posture.result) + return maybe_current_posture + + config.config_file.write() + class TrayIcon(QtGui.QSystemTrayIcon): def __init__(self, icon, parent=None): @@ -38,14 +55,14 @@ def __del__(self): def alert(self): # Alerting by receiving a signal - self.connect(self.workThread, QtCore.SIGNAL("slouching_alert(QString, QString)"), + self.connect(self.workThread, QtCore.SIGNAL("slouching_alert(QString, QString)"), self.showMessage) self.workThread.start() class WrapperWidget(QtGui.QWidget): def __init__(self, parent=None): QtGui.QWidget.__init__(self, parent) - + self.setGeometry(100, 100, 100, 100) self.setWindowTitle('threads') # self.show() @@ -59,7 +76,7 @@ def __init__(self): def __del__(self): self.wait() - # I can't get the timing right but I think having this + # I can't get the timing right but I think having this # will help kill our while loop in run() # This hopefully avoids a race condition where the camera is stuck active # if we quit while it's taking a picture. @@ -91,14 +108,14 @@ def run(self): slouching_messages = slouching_messages + "Your head is tilted!" if body_slouching or head_tilting: - self.emit(QtCore.SIGNAL('slouching_alert(QString, QString)'), + self.emit(QtCore.SIGNAL('slouching_alert(QString, QString)'), "Your posture is off!", str(slouching_messages)) else: - self.emit(QtCore.SIGNAL('slouching_alert(QString, QString)'), + self.emit(QtCore.SIGNAL('slouching_alert(QString, QString)'), "Error encountered", str(maybe_slouching.result)) - - time.sleep(check_frequency) + + time.sleep(float(check_frequency)) app = QtGui.QApplication(sys.argv) signal.signal(signal.SIGINT, signal.SIG_DFL) #Force PYQT to handle SIGINT (CTRL+C)