diff --git a/AUTHORS.TXT b/AUTHORS.TXT index 63af607c..880bcafd 100644 --- a/AUTHORS.TXT +++ b/AUTHORS.TXT @@ -1,2 +1,11 @@ +- Main developer: + Jesus Arias Fisteus + +- Manuscript digits classifier: + +Rodrigo Arguello + +- Exam configuration dialogs: + Jonathan Araneda Labarca diff --git a/Changelog b/Changelog index 503466a2..7c6c258b 100644 --- a/Changelog +++ b/Changelog @@ -1,3 +1,17 @@ +2017-07-24 Jesus Arias Fisteus + * Release 0.7. + + * Add an SVM-based OCR module for student's identity and cell crosses + + * Add support to create Windows installers with pyinstaller and NSIS, + and support for Linux portable executables with pyinstaller. + + * Add support to publish the project in PyPI + + * Transition to the cv2 bindings og OpenCV 2.4 + + * Several minor improvements and bug fixes + 2016-07-04 Jesus Arias Fisteus * Release 0.6.4. diff --git a/README b/README index 97e18054..df8e6868 100644 --- a/README +++ b/README @@ -1,4 +1,4 @@ -Eyegrade uses a webcam to grade MCQ (multiple choice question) +Eyegrade uses a webcam to grade multiple choice question (MCQ) exams. Needing just a cheap low-end webcam, it aims to be a low-cost and portable solution available to everyone, on the contrary to other solutions based on scanners. @@ -7,14 +7,13 @@ For more information about Eyegrade you can visit: - Its website: http://www.eyegrade.org/ - Its blog: http://www.eyegrade.org/blog/ -- The user manual: http://www.eyegrade.org/doc/user-manual/ +- Its documentation: http://www.eyegrade.org/documentation.html - Its source code at GitHub: https://github.com/jfisteus/eyegrade +- The downloads page, for pre-built binary files: + http://www.eyegrade.org/download.html -Eyegrade is still alpha. It is fully functional and has been used in -several courses at Universidad Carlos III de Madrid since 2010. -Its use is encouraged for tech-savvy people. However, it still -needs great improvements in its user-friendliness in order to be -usable for the general public. +Eyegrade is fully functional and has been used in courses at +Universidad Carlos III de Madrid and other institutions since 2010. The program is free software, licensed under the terms of the GNU General Public License (GPL) version 3 or any later version. @@ -39,15 +38,11 @@ student by using its hand-written digit cognition module. The whole process is supervised by the user in order to detect and fix potential errors of the system. -- Extracting statistics: you can view question-by-question statistics in -order to analyze which topics are clear to the majority of your -students, and which topics need further work. - - Exporting grades: grades can be exported in CSV format, compatible with other programs such as spreadsheets. -An article describing Eyegrade has been published by the Journal of -Science Education and Technology: +An article describing an earlier version of Eyegrade has been +published by the Journal of Science Education and Technology: Jesus Arias Fisteus, Abelardo Pardo and Norberto Fernández García, "Grading Multiple Choice Exams with Low-Cost and Portable diff --git a/bin/eyegrade b/bin/eyegrade new file mode 100755 index 00000000..df8a6b90 --- /dev/null +++ b/bin/eyegrade @@ -0,0 +1,41 @@ +#!/usr/bin/env python + +from __future__ import print_function + +import sys + + +dependencies = {} +try: + import cv2 + if cv2.__version__.startswith('2.4'): + dependencies['opencv'] = (True, None) + else: + dependencies['opencv'] = (False, 'version') +except ImportError: + dependencies['opencv'] = (False, 'import') + +try: + import PyQt4.QtCore + dependencies['pyqt4'] = (True, None) +except ImportError: + dependencies['pyqt4'] = (False, 'import') + +if all(value[0] for value in dependencies.values()): + import eyegrade.eyegrade + eyegrade.eyegrade.main() +else: + has_opencv, reason = dependencies['opencv'] + if not has_opencv: + if reason == 'version': + print('OpenCV 2.4 is required, but {} found.' + .format(cv2.__version__), + file=sys.stderr) + else: + print('Cannot import cv2. You need to install OpenCV 2.4 ' + 'and its Python bindings.', + file=sys.stderr) + has_pyqt4, reason = dependencies['pyqt4'] + if not has_pyqt4: + print('Cannot import PyQt4. You need to install it.', + file=sys.stderr) diff --git a/bin/eyegrade-create b/bin/eyegrade-create new file mode 100755 index 00000000..02218e96 --- /dev/null +++ b/bin/eyegrade-create @@ -0,0 +1,5 @@ +#!/usr/bin/env python + +import eyegrade.create_exam + +eyegrade.create_exam.main() diff --git a/doc/sample-files/exam-A.pdf b/doc/sample-files/exam-A.pdf index b0c5a95f..9d1b8343 100644 Binary files a/doc/sample-files/exam-A.pdf and b/doc/sample-files/exam-A.pdf differ diff --git a/doc/user-manual/user-manual.rst b/doc/user-manual/user-manual.rst index 29e2b60e..606cbac1 100644 --- a/doc/user-manual/user-manual.rst +++ b/doc/user-manual/user-manual.rst @@ -6,318 +6,150 @@ Eyegrade User Manual .. contents:: .. section-numbering:: -This user manual refers to Eyegrade 0.6 and later versions. +This user manual refers to Eyegrade 0.7 and later versions. For the -0.5 series see `the user manual version 0.5 <../user-manual-0.5/>`_. -For the -0.3 and 0.4 series see `the user manual version 0.4 <../user-manual-0.4/>`_. +0.6 series see `the user manual version 0.6 <../user-manual-0.6/>`_. + Installing Eyegrade ------------------- -Eyegrade depends on the following free-software projects: - -- Python_: the run-time environment and standard library for the - execution Python programs. Eyegrade is known to work with Python - 2.6. - -- Opencv_: a widely used computer-vision library. Version 2.0 or later - is needed. Not only the OpenCV library, but also the python bindings - distributed with it are needed. - -- Qt_: a multi-platform library for developing graphical user interfaces. - -- PyQt_: Python bindings to Qt. - -- Tre_: a library for regular expressions. Install version 0.8.0 or - later. Both the library and python bindings are needed. - -.. _Python: http://www.python.org/ -.. _Opencv: http://opencv.willowgarage.com/wiki/ -.. _Qt: http://qt.digia.com/ -.. _PyQt: http://www.riverbankcomputing.co.uk/software/pyqt/ -.. _Tre: http://laurikari.net/tre/ - - -Upgrading from Eyegrade 0.2.x, 0.3.x, 0.4.x and 0.5.x to Eyegrade 0.6 -...................................................................... - -In order to upgrade from Eyegrade 0.2.x, 0.3.x, 0.4.x and 0.5.x -to Eyegrade 0.6, -follow the instructions at `Updating Eyegrade`_. - -Be aware that Eyegrade 0.5 and 0.6 use an updated session database schema. -Although Eyegrade 0.5 and 0.6 are able to work with sessions created -by Eyegrade 0.4 and previous versions, -those previous versions don't work -with sessions created by Eyegrade 0.5 and 0.6. - -The main changes of the most recent versions are described in the following -blog posts: - -- `Eyegrade 0.6 released - `_ - -- `Eyegrade 0.5 released - `_ - -- `Eyegrade 0.4 released - `_ - -- `Eyegrade 0.3 released - `_ - Installation on GNU/Linux ......................... -If your Linux distribution is not very old, it should provide most of -the needed software packages. Specific instructions for Debian -GNU/Linux and Ubuntu are provided below. - - -Installation on Debian and Ubuntu -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Almost all the required software packages are already available in -recent versions of `Debian GNU/Linux `_ and -`Ubuntu `_. The only exception are the Python -bindings for Tre, which have to be installed manually. - -Using your favorite package manager (``apt-get``, ``aptitude``, -``synaptic``, etc.), install the following packages: - -- ``python`` (check that the version is 2.7.) - -- ``python-dev`` - -- ``python-qt4`` - -- ``python-opencv`` - -- ``python-numpy`` - -- ``libtre5`` - -- ``libtre-dev`` - -- ``git`` - -For example, with ``apt-get`` you would run from a command line terminal:: - - sudo apt-get install python python-dev python-qt4 python-opencv python-numpy libtre5 libtre-dev git - -Then, you have to install the Python bindings for Tre. In order to do -that, download, compile and install the Python bindings. You can do -that from a command line terminal:: - - wget http://laurikari.net/tre/tre-0.8.0.tar.gz - tar xzvf tre-0.8.0.tar.gz - cd tre-0.8.0/python/ - python setup.py build - sudo python setup.py install - -Now, you only need to download Eyegrade using the git source code -revision system:: - - cd $DIR - git clone -b master git://github.com/jfisteus/eyegrade.git - -Note: replace $DIR above with the directory in which you -want Eyegrade to be installed. - -Finally, add the ``$DIR/eyegrade`` directory to your ``PYTHONPATH`` and -check that Eyegrade works:: - - export PYTHONPATH=$DIR/eyegrade - python -m eyegrade.eyegrade - -The export command works only in the current terminal. You can make it -permanent by adding it to your $HOME/.bashrc file (if you use the BASH -shell). - -That's all! Eyegrade should now be installed. For further testing, go to -`Launching Eyegrade`_. +Eyegrade for Linux is distributed as a tarball file +containing the executable files +`eyegrade` (its main program) +and `eyegrade-create` +(the command line tool that creates the PDF files of the exam). +Download and uncompress the tarball file from the +`downloads page `_ +and save the binary files +in the location you prefer inside your account +or within a system-wide directory. +You may want to add the directory to your `PATH` +or place the binaries inside a directory that is already in your `PATH`. +For example, if you want to place the binaries +inside `/usr/local/bin`, which is usually in your `PATH`:: + + tar xavf eyegrade-0.7-linux-bin.tgz + sudo cp eyegrade-0.7-linux-bin/eyegrade /usr/local/bin + sudo cp eyegrade-0.7-linux-bin/eyegrade-create /usr/local/bin Installation on Microsoft Windows ................................. -You have to follow these steps, explained in the following sections, -in order to install Eyegrade in Windows: - -1.- Install Python 2.6 (including Tre). - -2.- Install PyQt. - -3.- Install OpenCV 2.1. - -4.- Install Eyegrade itself. - - -Installing Python -~~~~~~~~~~~~~~~~~ - -The easiest way to install Python, PyQt and Tre in Windows is -to download a ZIP file that contains all of them and extract it in -your file system. - -1.- Download the ZIP file from: -`Python26.zip `_. - -2.- Extract it somewhere in your file system (I recommend ``C:\``). A -directory named ``Python26`` will appear. Be aware that the full path -of the directory where you extract it *cannot contain* white-spaces. - -3.- Add the main directory (``Python26``) of your Python installation -to your system PATH. For example, if you uncompressed Python at ``C:\``, -add ``C:\Python26`` to the system PATH variable. - -You can test your installation by opening a new command line console -and launching the interactive Python interpreter in it:: - - Python +Download the Windows installer +from the `downloads page `_ +and run it. +Once installed, Eyegrade will be installed within your user's account +and accessible through your Start Menu. + +**Important:** +The security systems of Windows will probably alert you +that running the installer may be dangerous +because of it coming from an untrusted source. +The reason is that being Eyegrade free software +I cannot pay for a trusted certificate +with which to sign the installer. +If you want to be sure the installer has not been tampered with by anybody, +use the checksums from the downloads page. + +**Note:** +If you try to uninstall Eyegrade manually +or run the installer for a newer version, +the installer may fail with a message +saying that Eyegrade is running and should be closed first. +This message will appear also if there is any file manager window +positioned in a directory called *Eyegrade*. +Close the file manager window in that case and proceed again. -If it does not start, you have probably not added it correctly to your -system PATH. Opening a new console is important because changes in the -system PATH apply only to newly-opened consoles. -Once in the Python interpreter, the following command should work:: - - import tre - -This command should not output any message. If it does, there is a -problem with the installation. If *tre* complains about a missing DLL, -the problem is probably that the installation directory of Python is -not in the system PATH. - -If you already have a Python 2.6 installation and want to use it, you -must, on that installation of Python, download and install Tre -0.8.0. You will need Microsoft Visual Studio 2008 (the express version -is free and works) for this last step. - - -Installing PyQt4 -~~~~~~~~~~~~~~~~ - -`Download PyQt -`_. Select -the Windows 32-bit installer for Python 2.6, event if you have a -64-bit version of Windows. Alternatively, there is a copy of the file -you need at `PyQt-Py2.6-x86-gpl-4.9.6-1.exe -`_. - -Run the installer. From the optional software that the installer -suggests, you only need to select the *Qt runtime*. - - -Installing OpenCV -~~~~~~~~~~~~~~~~~ - -Download the EXE installer of OpenCV 2.1.0 for Windows platforms: -`OpenCV-2.1.0-win32-vs2008.exe -`_. There -is a copy of the same file at `OpenCV21.exe -`_. - -Execute the installer. Again, it is better to choose an installation -path which has no white-spaces in it. The installer will eventually -ask to put OpenCV in your system PATH. Answer *yes for this user* or -*yes for all the users*. - -In order to test the installation, open a *new* command prompt window -(it must necessarily be a new window for the system path to be -updated). Run the python interpreter as explained in the previous -section and type in it:: - - import cv - -This command should not output any message. If it does, there is a -problem with the installation. - - -Installing Eyegrade -~~~~~~~~~~~~~~~~~~~ - -By now, the recommended way to install Eyegrade is through the `Git -version control system `_. This way it will be -easier to update Eyegrade in the future, when new versions are -released (see `Updating Eyegrade`_). - -In order to install Eyegrade through Git, follow these steps: - -1.- Download and install Git if you do not have it installed. The -installer and installation instructions are available at -. - -2.- Open a command line prompt (for example, a Git shell), enter the -directory you want Eyegrade to be installed (again, with no -white-spaces in it), and type:: +Installation on Mac OS X +........................ - git clone -b master git://github.com/jfisteus/eyegrade.git +Unfortunately, I cannot provide support for Mac OS X. +I'm confident that Eyegrade should work on that platform out of the box +or with some minimal changes, +but I don't own a computer +in which to check and build an installer. +Volunteers to support Eyegrade on Mac OS X are welcome. -If you prefer not to install Git: -1.- Download the ZIP file `eyegrade-0.6.4.zip -`_. -Extract it in your file system, -in a directory with no white-spaces in its path. +Upgrading from Eyegrade 0.6.x and previous versions to Eyegrade 0.7 +...................................................................... -Once you have Eyegrade installed (either with or without Git), test -it. For example, if you have installed both Python and Eyegrade at -``C:\``:: +The installation procedures for Eyegrade 0.7 are new. +Instead of upgrading through Git like in previous versions, +I recommend directly removing your installation +and doing a fresh installation following the instructions +in the next sections. - set PYTHONPATH=C:\eyegrade - C:\Python26\python -m eyegrade.eyegrade +On Windows, one possible procedure for uninstalling everything, +assuming you've followed the installation instructions +for Eyegrade 0.6.x or a previous version, is: -It should dump a help message. +- If you don't need them for other software, + run the uninstallers of PyQT4, OpenCV and Git. -**Tip:** it may be convenient adding C:\Python26 to your system path -permanently, and adding PYTHONPATH to the system-wide environment -variables. There are plenty of resources in the Web that explain how -to do this. For example, -``_. +- Remove your Python 2.6 installation directory + (probably ``C:\Python26``) + from your system's + PATH environment variable, if it's there. -Eyegrade should now be installed. Nevertheless, it might be a good -idea to reboot now the computer, in order to guarantee that the -installation of OpenCV and PyQt has completed. After that, go to -`Launching Eyegrade`_. +- Remove your Python's installation directory + (probably ``C:\Python26``). +- Remove the directory of Eyegrade + (probably ``C:\eyegrade``). -Installation on Mac OS X -........................ +On Linux, you can uninstall some packages +that previous versions of Eyegrade depended on +but are now bundled inside the binaries you'll download. +If you know no other software in your installation needs them, +you may remove +``python-qt4``, ``python-opencv``, ``git``, ``python-dev``, +``libtre5``, ``libtre-dev``. +This step is optional, +since Eyegrade will work normally even if you don't uninstall them. -Sorry, Eyegrade is not currently supported on that platform. Volunteers -to support the platform are welcome. +The main changes of the most recent versions are described in the following +blog posts: +- `Eyegrade 0.7 released + `_ -Updating Eyegrade -................. +- `Eyegrade 0.6 released + `_ -From time to time, a new release of Eyegrade may appear. If you -installed Eyegrade using Git, updating is simple. Open a command -prompt window, enter the Eyegrade installation directory and type:: +- `Eyegrade 0.5 released + `_ - git pull +- `Eyegrade 0.4 released + `_ -This should work on any platform (Linux, Windows, etc.) +- `Eyegrade 0.3 released + `_ -If you didn't use Git to install Eyegrade, `download the new version -`_, -uncompress it and replace your ``eyegrade`` directory by the one you -have uncompressed. Grading Exams ------------- +If it is the first time you run Eyegrade, +it is recommended to go first through the +`Quick start guide `_. +From now on, it is assumed that you've already done that. + The main purpose of Eyegrade is grading exams. In order to grade exams, you will need: - The Eyegrade software installed in your computer. - The exam configuration file, which specifies the number of questions in the exam, solutions, etc. It is normally named with the - `.eye`extension, such as `exam.eye`. + `.eye` extension, such as `exam.eye`. - A compatible webcam, with resolution of at least 640x480. It is better if it is able to focus (manually or automatically) at short distances. @@ -329,18 +161,16 @@ you will need: Launching Eyegrade .................. -This section explains how to run Eyegrade. If it is the first time you -use Eyegrade, you can try it with the sample file ``exam-A.pdf`` -located inside the directory ``doc/sample-files`` of your installation +If it is the first time you use Eyegrade, +you can try it with the sample file ``exam-A.pdf`` +located inside the directory ``examples`` of your installation of Eyegrade. Print it. You'll find also in that directory the file ``exam.eye`` that contains the metadata for this exam. You'll need to load this file later from Eyegrade. -Eyegrade can be launched from command line:: - - python -m eyegrade.eyegrade - -This command opens the user interface of Eyegrade: +On Windows, launch Eyegrade from the Start Menu. +On Linux, run the `eyegrade` binary file you should have installed. +Eyegrade's main window should appear: .. image:: images/main-window.png :alt: Eyegrade main window @@ -1107,10 +937,8 @@ which are described in the next sections: instructions to students, etc. This template is reusable for other exams in the future. -#. Automatically generate the LaTeX source files - from the XML file and the template. - -#. Generate the PDF files from the LaTeX source files. +#. Automatically generate the PDF files + from the XML file and the LaTeX template. The example files used in the following explanations are provided with Eyegrade @@ -1306,16 +1134,33 @@ Note that a template is highly reusable for different exams and subjects. -Creating the LaTeX source files +Creating the PDF files ................................ -Once the exam file and the template have been created, the script -`create_exam.py` parses them and generates the exam in LaTeX format:: - - python -m eyegrade.create_exam -e exam-questions.xml -m 0AB template.tex -o exam - -The previous command will create models 0, A and B of the exam with -names `exam-0.tex`, `exam-A.tex` and `exam-B.tex`. The exam model 0 is a +Once the exam file and the template have been created, +the `eyegrade-create` program parses them and generates the exam in PDF format, +provided that LaTeX is installed and available in your system's PATH +(see `Installing the LaTeX system`_). + +You'll need the `eyegrade-create.exe` (just `eyegrade-create` on Linux) +binary file. +Note that, on Windows, you can get this file from +Eyegrade's installation folder, +which you can open from Eyegrade's entry at the Start Menu. +The only way to run `eyegrade-create` by now is from a command-prompt console. +Therefore, it is better to +copy the `eyegrade-create` program +somewhere in your +computer where you find it convenient +(for example, the directory where you have prepared your exam's source files). + +Once you have located `eyegrade-create`, run the following command +from a command-line console:: + + eyegrade-create -e exam-questions.xml -m 0AB template.tex -o exam + +It will create models 0, A and B of the exam with +names `exam-0.pdf`, `exam-A.pdf` and `exam-B.pdf`. The exam model 0 is a special exam in which questions are not reordered. The correct answer is always the first choice in the model 0. The model 0 is convenient while editing the questions, @@ -1324,37 +1169,37 @@ but you must remember not to use it in the exam itself. In addition, Eyegrade will automatically create the ``exam.eye`` file needed to grade the exams, or update it if it already exists. +If Eyegrade encounters an error in the process, +you'll see the reason of the error in one of the following two ways: + +- If the error is in the XML syntax of the file with the questions, + you'll get the error message and line of the XML file + in which it was encountered. + +- If the error is in your LaTeX template + or in the LaTeX markup of your questions, + you'll get the output of the LaTeX command, + which will tell the line in which it happened + relative to the temporary LaTeX file Eyegrade has created + (e.g. `exam-0.tex`). + Eyegrade will leave this file, + as well as the full transcript of the error (e.g. `exam-0.log`) + in the same directory + in which the output would be produced in order to help you locate + the reason of the error. + The script `create_exam.py` has other features, like creating just the front page of the exam (no questions needed). They can be explored with the command-line help of the program:: - python -m eyegrade.create_exam -h + eyegrade-create -h The answer table can be enlarged or reduced with respect to its default size, using the `-S` option and passing a scale factor (between 0.1 and 1.0 to reduce it, or greater than 1.0 to enlarge it). The following command enlarges the default size in a 50% (factor 1.5):: - python -m eyegrade.create_exam -e exam-questions.xml -m A template.tex -o exam -S 1.5 - - -Creating the PDF files -....................... - -Once the `.tex` files have been created, -you have to use LaTeX to produce the PDF files. -For each file, run the following command:: - - pdflatex exam-A.tex - -If you have several exam models, -running that command for each one may be tedious. -On Linux systems you can produce all of them -with just a couple of commands:: - - find -name "exam-*.tex" -exec pdflatex \{\} \; - -That's it! Now you can print the PDF files of your exams. + eyegrade-create -e exam-questions.xml -m A template.tex -o exam -S 1.5 Installing the LaTeX system @@ -1385,39 +1230,6 @@ such as `ProText `_: Advanced features ----------------- -Webcam selection -................ - -If your computer has more than one camera (e.g. the internal camera of -the laptop and an external camera you use to grade the exams), -Eyegrade will select one of them by default. If the selected camera is -not the camera you want to use to grade the exams, use the ``-c -`` option when invoking Eyegrade. Cameras are numbered -0, 1, 2, 3, etc. Invoke Eyegrade with a different camera number until -the interface displays the one you want. For example, to select the -camera numbered as 2:: - - python -m eyegrade.eyegrade exam.eye -c 2 -l student-list.csv - -When the number is -1, eyegrade will automatically test different -camera numbers until it finds one that works. When you select a camera -number that does not exist or does not work, Eyegrade will also look -automatically for other camera that works. - -You can configure Eyegrade to always use a specific camera number by -inserting the option ``camera-dev`` in the ``default`` section of -the configuration file:: - - ## Sample configuration file. Save it as $HOME/.eyegrade.cfg - [default] - - ## Default camera device to use (int); -1 for automatic selection. - camera-dev: 1 - -Save it in your user account with name ``.eyegrade.cfg``. In Windows systems, -your account is at ``C:\Documents and Settings\``. - - Creating the exams in a word processor ........................................ diff --git a/eyegrade/capture.py b/eyegrade/capture.py index f2cfffaa..4f16430e 100644 --- a/eyegrade/capture.py +++ b/eyegrade/capture.py @@ -16,14 +16,11 @@ # . # -# Import the cv module. It might be cv2.cv in newer versions. -try: - import cv -except ImportError: - import cv2.cv as cv +import cv2 -import geometry -import utils +from . import geometry +from . import utils +from . import images _color_blue = (255, 0, 0) _color_good = (0, 210, 0) @@ -109,10 +106,10 @@ def __init__(self, image, answer_cells, id_cells, progress=1.0): self.reset_image() def has_answer_cells(self): - return len(self.answer_cells) > 0 + return self.answer_cells is not None and len(self.answer_cells) > 0 def has_id_cells(self): - return len(self.id_cells) > 0 + return self.id_cells is not None and len(self.id_cells) > 0 def get_cell_clicked(self, point): """Determines the cell to which the given point corresponds. @@ -144,14 +141,14 @@ def reset_image(self): """ if self.image_raw is not None: - self.image_drawn = cv.CloneImage(self.image_raw) + self.image_drawn = self.image_raw.copy() def save_image_drawn(self, filename): assert self.image_drawn is not None - cv.SaveImage(filename, self.image_drawn) + save_image(filename, self.image_drawn) def save_image_raw(self, filename): - cv.SaveImage(filename, self.image_raw) + save_image(filename, self.image_raw) def draw_status(self): assert self.image_drawn is not None @@ -159,7 +156,7 @@ def draw_status(self): def draw_corner(self, point): assert self.image_drawn is not None - cv.Circle(self.image_drawn, point, 4, _color_blue, 1) + cv2.circle(self.image_drawn, point, 4, _color_blue, thickness=1) def draw_answers(self, score): assert self.image_drawn is not None @@ -170,15 +167,15 @@ def draw_answers(self, score): self._draw_answers_no_solutions(score) def _draw_status_bar(self): - x0 = self.image_drawn.width - 60 + x0 = images.width(self.image_drawn) - 60 y0 = 10 width = 50 height = 20 p0 = (x0, y0) p1 = geometry.round_point((x0 + self.progress * width, y0 + height)) p2 = (x0 + width, y0 + height) - cv.Rectangle(self.image_drawn, p0, p2, _color_blue) - cv.Rectangle(self.image_drawn, p0, p1, _color_blue, cv.CV_FILLED) + cv2.rectangle(self.image_drawn, p0, p2, _color_blue) + cv2.rectangle(self.image_drawn, p0, p1, _color_blue, thickness=-1) def _draw_answers_solutions(self, score): for answer, solution, status, cells in zip(score.answers, @@ -202,16 +199,17 @@ def _draw_answers_no_solutions(self, score): def _draw_cell_circle(self, cell, color): radius = int(round(cell.diagonal / 3.5)) - cv.Circle(self.image_drawn, cell.center, radius, color, 2) + cv2.circle(self.image_drawn, cell.center, radius, color, thickness=2) def _draw_cell_center(self, cell, color): - cv.Circle(self.image_drawn, cell.center, 4, color, cv.CV_FILLED) + cv2.circle(self.image_drawn, cell.center, 4, color, thickness=-1) def _draw_void_question(self, cells): - cv.Line(self.image_drawn, cells[0].center, cells[-1].center, - _color_bad, 3) + cv2.line(self.image_drawn, cells[0].center, cells[-1].center, + _color_bad, thickness=3) -def load_image(filename): - """Loads an OpenCV image from file.""" - return cv.LoadImage(filename) +def save_image(filename, image): + if isinstance(filename, unicode): + filename = utils.unicode_path_to_str(filename) + cv2.imwrite(filename, image) diff --git a/eyegrade/create_exam.py b/eyegrade/create_exam.py index fc9aae4f..21b43442 100644 --- a/eyegrade/create_exam.py +++ b/eyegrade/create_exam.py @@ -15,6 +15,7 @@ # along with this program. If not, see # . # +from __future__ import print_function from optparse import OptionParser import sys @@ -95,6 +96,10 @@ def read_cmd_options(): dest='survey_mode', action='store_true', default=False, help=('this is a survey instead of an exam')) + parser.add_option('--no-pdf', + dest='no_pdf', + action='store_true', default=False, + help=('produce the .tex files instead of PDF')) (options, args) = parser.parse_args() if len(args) != 1: parser.error('Required parameters expected') @@ -113,8 +118,19 @@ def read_cmd_options(): if options.table_scale < 0.1: parser.error('The scale factor must be positive and greater or equal' ' to 0.1') + options.output_file_prefix = _arg_to_unicode(options.output_file_prefix) + options.exam_filename = _arg_to_unicode(options.exam_filename) + options.subject = _arg_to_unicode(options.subject) + options.degree = _arg_to_unicode(options.degree) + options.title = _arg_to_unicode(options.title) return options, args +def _arg_to_unicode(arg_value): + if arg_value is not None: + return utils.path_to_unicode(arg_value) + else: + return None + def create_exam(): options, args = read_cmd_options() template_filename = args[0] @@ -141,8 +157,9 @@ def create_exam(): num_questions = exam.num_questions() num_choices = exam.num_choices() if not exam.homogeneous_num_choices(): - print >>sys.stderr, ('Warning: not all the questions have ' - 'the same number of choices.') + print(('Warning: not all the questions have ' + 'the same number of choices.'), + file=sys.stderr) if num_choices is None: raise Exception('All the questions in the exam must have the ' 'same number of choices') @@ -213,26 +230,42 @@ def create_exam(): score_weights, options.left_to_right_numbering, options.survey_mode) + if not options.no_pdf and options.output_file_prefix is not None: + if exammaker.check_latex(): + produce_pdf = True + else: + produce_pdf = False + print('Warning: pdflatex not found in your system PATH', + file=sys.stderr) + else: + produce_pdf = False if exam is not None: maker.set_exam_questions(exam) for model in options.models: - maker.create_exam(model, not options.dont_shuffle_again) + produced_filename = maker.create_exam(model, + not options.dont_shuffle_again, + produce_pdf=produce_pdf) + print('Created file:', produced_filename, file=sys.stderr) if options.output_file_prefix is not None: maker.output_file = options.output_file_prefix + '-%s-solutions.tex' for model in options.models: - maker.create_exam(model, False, with_solution=True) + produced_filename = maker.create_exam(model, + False, + with_solution=True, + produce_pdf=produce_pdf) + print('Created file:', produced_filename, file=sys.stderr) if config_filename is not None: maker.save_exam_config() # Dump some final warnings for key in maker.empty_variables: - print >>sys.stderr, 'Warning: empty \'%s\' variable'%key + print('Warning: empty \'%s\' variable'%key, file=sys.stderr) def main(): try: create_exam() except utils.EyegradeException as ex: - print >>sys.stderr, ex + print(ex, file=sys.stderr) if __name__ == '__main__': main() diff --git a/eyegrade/data/eyegrade.ico b/eyegrade/data/eyegrade.ico new file mode 100644 index 00000000..593495a0 Binary files /dev/null and b/eyegrade/data/eyegrade.ico differ diff --git a/eyegrade/data/locale/ca/LC_MESSAGES/eyegrade.mo b/eyegrade/data/locale/ca/LC_MESSAGES/eyegrade.mo index ffa285f8..3978834c 100644 Binary files a/eyegrade/data/locale/ca/LC_MESSAGES/eyegrade.mo and b/eyegrade/data/locale/ca/LC_MESSAGES/eyegrade.mo differ diff --git a/eyegrade/data/locale/ca/LC_MESSAGES/eyegrade.po b/eyegrade/data/locale/ca/LC_MESSAGES/eyegrade.po index 9ef90e6c..6c70cccd 100644 --- a/eyegrade/data/locale/ca/LC_MESSAGES/eyegrade.po +++ b/eyegrade/data/locale/ca/LC_MESSAGES/eyegrade.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2015-06-18 19:18+0200\n" +"POT-Creation-Date: 2017-03-10 00:13+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -51,32 +51,32 @@ msgstr "" "Format de sessió incompatible. Això és {0} versió {1}, però la sessió fou " "creada amb la versió {2}" -#: eyegrade/eyegrade.py:232 +#: eyegrade/eyegrade.py:209 msgid "The directory has no Eyegrade session" msgstr "No hi ha cap sessió Eyegrade en el directori" -#: eyegrade/eyegrade.py:234 +#: eyegrade/eyegrade.py:211 msgid "Error opening the session file" msgstr "Error al obrir l'arxiu de la sessió" -#: eyegrade/eyegrade.py:424 +#: eyegrade/eyegrade.py:403 #, python-brace-format msgid "There are no solutions for model {0}." msgstr "No hi ha solucions per al model {0}" -#: eyegrade/eyegrade.py:443 +#: eyegrade/eyegrade.py:422 msgid "Input/output error:" msgstr "Error d'entrada/sortida" -#: eyegrade/eyegrade.py:446 +#: eyegrade/eyegrade.py:425 msgid "Error:" msgstr "Error:" -#: eyegrade/eyegrade.py:484 eyegrade/eyegrade.py:489 +#: eyegrade/eyegrade.py:463 eyegrade/eyegrade.py:468 msgid "Error loading the session" msgstr "Error al carregar la sessió" -#: eyegrade/eyegrade.py:496 +#: eyegrade/eyegrade.py:475 msgid "" "The current capture has not been saved and will be lost. Are you sure you " "want to close this session?" @@ -84,7 +84,7 @@ msgstr "" "La captura actual no ha estat guardada i es perdrà. Està segur de que vol " "tancar la sessió?" -#: eyegrade/eyegrade.py:514 +#: eyegrade/eyegrade.py:493 msgid "" "The current capture has not been saved and will be lost. Are you sure you " "want to exit the application?" @@ -92,28 +92,28 @@ msgstr "" "La captura actual no ha estat guardada i es perdrà. Està segur de que vol " "sortir del programa?" -#: eyegrade/eyegrade.py:571 +#: eyegrade/eyegrade.py:550 msgid "The selected exam will be removed. Are you sure?" msgstr "S'esborrarà l'examen seleccionat. Està segur?" -#: eyegrade/eyegrade.py:663 eyegrade/eyegrade.py:676 +#: eyegrade/eyegrade.py:642 eyegrade/eyegrade.py:655 #, fuzzy, python-brace-format msgid "Input/output error: {0}" msgstr "Error d'entrada/sortida" -#: eyegrade/eyegrade.py:666 eyegrade/eyegrade.py:679 +#: eyegrade/eyegrade.py:645 eyegrade/eyegrade.py:658 msgid "The file has been saved." msgstr "" -#: eyegrade/eyegrade.py:667 eyegrade/eyegrade.py:680 +#: eyegrade/eyegrade.py:646 eyegrade/eyegrade.py:659 msgid "File saved" msgstr "" -#: eyegrade/eyegrade.py:720 +#: eyegrade/eyegrade.py:701 msgid "Manual detection failed" msgstr "Ha fallat la detecció manual" -#: eyegrade/eyegrade.py:759 +#: eyegrade/eyegrade.py:741 msgid "No camera found. Connect a camera and start the session again." msgstr "" "No s'ha trobat cap càmara. Connecti'n una i obri la sessió un altre cop." @@ -261,49 +261,49 @@ msgstr "Model:" msgid "Num.:" msgstr "Num." -#: eyegrade/qtgui/gui.py:579 +#: eyegrade/qtgui/gui.py:582 msgid "Grading - Scanning exam" msgstr "Corregint - Escanejant un nou examen" -#: eyegrade/qtgui/gui.py:584 +#: eyegrade/qtgui/gui.py:587 msgid "Grading - Reviewing exam" msgstr "Corregint - Revisió de la correcció" -#: eyegrade/qtgui/gui.py:587 +#: eyegrade/qtgui/gui.py:590 msgid "Session open - Reviewing exam" msgstr "Sessió oberta - Revisió de la correcció" -#: eyegrade/qtgui/gui.py:591 +#: eyegrade/qtgui/gui.py:594 msgid "Click on the outer corners of the answer tables" msgstr "Fes click a les cantonades de les taules de respostes" -#: eyegrade/qtgui/gui.py:593 +#: eyegrade/qtgui/gui.py:596 msgid "Grading - Manual detection mode" msgstr "Corregint - Mode de detecció manual" -#: eyegrade/qtgui/gui.py:599 +#: eyegrade/qtgui/gui.py:603 msgid "Session open" msgstr "Sessió oberta" -#: eyegrade/qtgui/gui.py:605 +#: eyegrade/qtgui/gui.py:609 msgid "No session: open or create a session to start" msgstr "No hi ha sessió oberta: obri o creï una sessió per començar" -#: eyegrade/qtgui/gui.py:762 +#: eyegrade/qtgui/gui.py:766 msgid "Select the session file" msgstr "Seleccionar el fitxer de sessió" -#: eyegrade/qtgui/gui.py:799 +#: eyegrade/qtgui/gui.py:803 #, fuzzy msgid "Save exam configration as..." msgstr "Seleccioni el fitxer de configuració de l'examen" -#: eyegrade/qtgui/gui.py:800 eyegrade/qtgui/__init__.py:31 +#: eyegrade/qtgui/gui.py:804 eyegrade/qtgui/__init__.py:31 msgid "Exam configuration (*.eye)" msgstr "Configuració d'examen (*.eye)" -#: eyegrade/qtgui/gui.py:835 eyegrade/qtgui/gui.py:841 -#: eyegrade/qtgui/wizards.py:762 +#: eyegrade/qtgui/gui.py:839 eyegrade/qtgui/gui.py:845 +#: eyegrade/qtgui/wizards.py:774 msgid "Warning" msgstr "Avís" @@ -434,118 +434,118 @@ msgstr "&Nova sessió" msgid "Student id:" msgstr "Estudiant:" -#: eyegrade/qtgui/dialogs.py:111 +#: eyegrade/qtgui/dialogs.py:112 msgid "Add a new student" msgstr "" -#: eyegrade/qtgui/dialogs.py:120 +#: eyegrade/qtgui/dialogs.py:121 #, fuzzy msgid "Given name" msgstr "Estudiant:" -#: eyegrade/qtgui/dialogs.py:121 +#: eyegrade/qtgui/dialogs.py:122 #, fuzzy msgid "Surname" msgstr "Estudiant:" -#: eyegrade/qtgui/dialogs.py:122 +#: eyegrade/qtgui/dialogs.py:123 msgid "Full name" msgstr "" -#: eyegrade/qtgui/dialogs.py:128 +#: eyegrade/qtgui/dialogs.py:129 msgid "Separate given name and surname" msgstr "" -#: eyegrade/qtgui/dialogs.py:129 +#: eyegrade/qtgui/dialogs.py:130 msgid "Full name in just one field" msgstr "" -#: eyegrade/qtgui/dialogs.py:132 +#: eyegrade/qtgui/dialogs.py:133 #, fuzzy msgid "Id number" msgstr "Llistes d'estudiants" -#: eyegrade/qtgui/dialogs.py:136 +#: eyegrade/qtgui/dialogs.py:137 msgid "Email" msgstr "" -#: eyegrade/qtgui/dialogs.py:219 eyegrade/qtgui/wizards.py:552 +#: eyegrade/qtgui/dialogs.py:220 eyegrade/qtgui/wizards.py:564 msgid "Compute default scores" msgstr "Calcular notes per defecte" -#: eyegrade/qtgui/dialogs.py:223 +#: eyegrade/qtgui/dialogs.py:224 msgid "Penalize incorrect answers" msgstr "Penalitzar les respostes incorrectes" -#: eyegrade/qtgui/dialogs.py:226 +#: eyegrade/qtgui/dialogs.py:227 msgid "Maximum score" msgstr "Nota màxima" -#: eyegrade/qtgui/dialogs.py:227 +#: eyegrade/qtgui/dialogs.py:228 msgid "Penalizations" msgstr "Penalitzacions" -#: eyegrade/qtgui/dialogs.py:251 eyegrade/qtgui/widgets.py:230 -#: eyegrade/qtgui/wizards.py:315 eyegrade/qtgui/wizards.py:508 -#: eyegrade/qtgui/wizards.py:703 eyegrade/qtgui/wizards.py:738 -#: eyegrade/qtgui/wizards.py:742 eyegrade/qtgui/wizards.py:754 +#: eyegrade/qtgui/dialogs.py:252 eyegrade/qtgui/widgets.py:242 +#: eyegrade/qtgui/wizards.py:327 eyegrade/qtgui/wizards.py:520 +#: eyegrade/qtgui/wizards.py:715 eyegrade/qtgui/wizards.py:750 +#: eyegrade/qtgui/wizards.py:754 eyegrade/qtgui/wizards.py:766 msgid "Error" msgstr "Error" -#: eyegrade/qtgui/dialogs.py:252 +#: eyegrade/qtgui/dialogs.py:253 msgid "Enter a valid score." msgstr "Introdueixi una nota vàlida" -#: eyegrade/qtgui/dialogs.py:283 +#: eyegrade/qtgui/dialogs.py:284 msgid "Select a camera" msgstr "Seleccioni una càmara" -#: eyegrade/qtgui/dialogs.py:288 +#: eyegrade/qtgui/dialogs.py:289 msgid "Try this camera" msgstr "Provar aquesta càmara" -#: eyegrade/qtgui/dialogs.py:322 eyegrade/qtgui/dialogs.py:337 +#: eyegrade/qtgui/dialogs.py:323 eyegrade/qtgui/dialogs.py:338 msgid "Camera not available" msgstr "Càmara no disponible" -#: eyegrade/qtgui/dialogs.py:323 +#: eyegrade/qtgui/dialogs.py:324 msgid "Eyegrade has not detected any camera in your system." msgstr "Eyegrade no ha detectat cap càmara." -#: eyegrade/qtgui/dialogs.py:338 +#: eyegrade/qtgui/dialogs.py:339 #, python-brace-format msgid "Camera {0} is not available." msgstr "Càmara {0} no està disponible." -#: eyegrade/qtgui/dialogs.py:343 +#: eyegrade/qtgui/dialogs.py:344 #, python-brace-format msgid "
Viewing camera: {0}
" msgstr "
Veient càmara: {0}
" -#: eyegrade/qtgui/dialogs.py:347 +#: eyegrade/qtgui/dialogs.py:348 msgid "
No camera
" msgstr "No hi ha càmara" -#: eyegrade/qtgui/dialogs.py:371 eyegrade/qtgui/dialogs.py:378 +#: eyegrade/qtgui/dialogs.py:372 eyegrade/qtgui/dialogs.py:379 msgid "About" msgstr "Sobre" -#: eyegrade/qtgui/dialogs.py:379 +#: eyegrade/qtgui/dialogs.py:380 msgid "Developers" msgstr "Desenvolupadors" -#: eyegrade/qtgui/dialogs.py:380 +#: eyegrade/qtgui/dialogs.py:381 msgid "Translators" msgstr "Traductors" -#: eyegrade/qtgui/dialogs.py:386 +#: eyegrade/qtgui/dialogs.py:387 #, fuzzy, python-brace-format msgid "" "\n" "
\n" "


\n" " {1} {2}
\n" -" (c) 2010-2015 Jesús Arias Fisteus
\n" +" (c) 2010-2016 Jesús Arias Fisteus and contributors
\n" " {3}
\n" " {4}\n" "\n" @@ -606,65 +606,73 @@ msgstr "" "

\n" " " -#: eyegrade/qtgui/dialogs.py:434 +#: eyegrade/qtgui/dialogs.py:439 msgid "Lead developers" msgstr "Desenvolupadors principals" -#: eyegrade/qtgui/dialogs.py:435 +#: eyegrade/qtgui/dialogs.py:440 #, fuzzy msgid "Exam configuration dialogs" msgstr "Fitxer de configuració de l'examen" -#: eyegrade/qtgui/dialogs.py:448 +#: eyegrade/qtgui/dialogs.py:441 +msgid "Manuscript digits recognition" +msgstr "" + +#: eyegrade/qtgui/dialogs.py:442 +msgid "Testing and other contributions" +msgstr "" + +#: eyegrade/qtgui/dialogs.py:455 msgid "Catalan" msgstr "Català" -#: eyegrade/qtgui/dialogs.py:449 +#: eyegrade/qtgui/dialogs.py:456 msgid "German" msgstr "Alemany" -#: eyegrade/qtgui/dialogs.py:450 +#: eyegrade/qtgui/dialogs.py:457 msgid "Galician" msgstr "Galleg" -#: eyegrade/qtgui/dialogs.py:451 +#: eyegrade/qtgui/dialogs.py:458 msgid "French" msgstr "Francès" -#: eyegrade/qtgui/dialogs.py:452 +#: eyegrade/qtgui/dialogs.py:459 msgid "Portuguese" msgstr "Portuguès" -#: eyegrade/qtgui/dialogs.py:453 +#: eyegrade/qtgui/dialogs.py:460 msgid "Spanish" msgstr "Espanyol" -#: eyegrade/qtgui/widgets.py:259 +#: eyegrade/qtgui/widgets.py:271 msgid "e.g.: 2; 2.5; 5/2" msgstr "exemples: 2; 2.5; 5/2" -#: eyegrade/qtgui/widgets.py:261 +#: eyegrade/qtgui/widgets.py:273 msgid "e.g.: 0; -1; -1.25; -5/4" msgstr "exemples: 0; -1; -1.25; -5/4" -#: eyegrade/qtgui/widgets.py:333 +#: eyegrade/qtgui/widgets.py:345 msgid "Add files" msgstr "Afegir fitxers" -#: eyegrade/qtgui/widgets.py:334 +#: eyegrade/qtgui/widgets.py:346 msgid "Remove selected" msgstr "Eliminar seleccionats" -#: eyegrade/qtgui/widgets.py:630 +#: eyegrade/qtgui/widgets.py:642 #, fuzzy msgid "Model" msgstr "Model:" -#: eyegrade/qtgui/widgets.py:633 +#: eyegrade/qtgui/widgets.py:645 msgid "Question" msgstr "" -#: eyegrade/qtgui/widgets.py:635 +#: eyegrade/qtgui/widgets.py:647 msgid "Total" msgstr "" @@ -725,11 +733,11 @@ msgstr "El fitxer de configuració de l'examen no existeix." msgid "The exam configuration file is not a regular file." msgstr "El fitxer de configuració de l'examen no és un fitxer regular." -#: eyegrade/qtgui/wizards.py:129 eyegrade/qtgui/wizards.py:306 +#: eyegrade/qtgui/wizards.py:129 eyegrade/qtgui/wizards.py:318 msgid "The exam configuration file cannot be read." msgstr "El fitxer de configuració de l'examen no es pot llegir." -#: eyegrade/qtgui/wizards.py:132 eyegrade/qtgui/wizards.py:309 +#: eyegrade/qtgui/wizards.py:132 eyegrade/qtgui/wizards.py:321 msgid "The exam configuration file contains errors" msgstr "L'arxiu de configuració de l'examen conté errors" @@ -798,73 +806,73 @@ msgstr "Puntuació per a les respostes correctes" msgid "Select the correct answers for each exam model" msgstr "" -#: eyegrade/qtgui/wizards.py:263 eyegrade/qtgui/wizards.py:370 +#: eyegrade/qtgui/wizards.py:275 eyegrade/qtgui/wizards.py:382 #, fuzzy msgid "Model " msgstr "Model:" -#: eyegrade/qtgui/wizards.py:287 +#: eyegrade/qtgui/wizards.py:299 #, fuzzy msgid "You haven't entered the correct answer for some questions." msgstr "Puntuació per a les respostes correctes" -#: eyegrade/qtgui/wizards.py:332 +#: eyegrade/qtgui/wizards.py:344 msgid "Configuration of permutations" msgstr "" -#: eyegrade/qtgui/wizards.py:333 +#: eyegrade/qtgui/wizards.py:345 msgid "" "Select the position of each question and its choices in every model of the " "exam." msgstr "" -#: eyegrade/qtgui/wizards.py:339 +#: eyegrade/qtgui/wizards.py:351 msgid "Questions of model A" msgstr "" -#: eyegrade/qtgui/wizards.py:341 +#: eyegrade/qtgui/wizards.py:353 msgid "Model equivalence" msgstr "" -#: eyegrade/qtgui/wizards.py:359 eyegrade/qtgui/wizards.py:361 +#: eyegrade/qtgui/wizards.py:371 eyegrade/qtgui/wizards.py:373 msgid "Question " msgstr "" -#: eyegrade/qtgui/wizards.py:396 +#: eyegrade/qtgui/wizards.py:408 #, fuzzy msgid "Question Number" msgstr "Llistes d'estudiants" -#: eyegrade/qtgui/wizards.py:400 +#: eyegrade/qtgui/wizards.py:412 #, fuzzy msgid "Save values" msgstr "Netejar valors" -#: eyegrade/qtgui/wizards.py:426 +#: eyegrade/qtgui/wizards.py:438 msgid "Information status" msgstr "" -#: eyegrade/qtgui/wizards.py:427 +#: eyegrade/qtgui/wizards.py:439 msgid "The values for the question have been successfully saved" msgstr "" -#: eyegrade/qtgui/wizards.py:430 +#: eyegrade/qtgui/wizards.py:442 msgid "Error in grid" msgstr "" -#: eyegrade/qtgui/wizards.py:431 +#: eyegrade/qtgui/wizards.py:443 msgid "There is an inconsistence in the options" msgstr "" -#: eyegrade/qtgui/wizards.py:505 +#: eyegrade/qtgui/wizards.py:517 msgid "You must select all permutations for all questions" msgstr "" -#: eyegrade/qtgui/wizards.py:523 +#: eyegrade/qtgui/wizards.py:535 msgid "Scores for correct and incorrect answers" msgstr "Puntuació per a les respostes correctes i incorrectes" -#: eyegrade/qtgui/wizards.py:524 +#: eyegrade/qtgui/wizards.py:536 msgid "" "Enter the scores of correct and incorrect answers. The program will compute " "scores based on them. Setting these scores is optional." @@ -872,59 +880,59 @@ msgstr "" "Introdueixi la puntuació per a les respostes correctes in incorrectes amb " "les que es calcularan les notes. Aquest pas és opcional." -#: eyegrade/qtgui/wizards.py:540 +#: eyegrade/qtgui/wizards.py:552 msgid "No scores" msgstr "" -#: eyegrade/qtgui/wizards.py:541 +#: eyegrade/qtgui/wizards.py:553 msgid "Same score for all the questions" msgstr "" -#: eyegrade/qtgui/wizards.py:542 +#: eyegrade/qtgui/wizards.py:554 msgid "Base score plus per-question weight" msgstr "" -#: eyegrade/qtgui/wizards.py:546 +#: eyegrade/qtgui/wizards.py:558 msgid "Score for correct answers" msgstr "Puntuació per a les respostes correctes" -#: eyegrade/qtgui/wizards.py:547 +#: eyegrade/qtgui/wizards.py:559 msgid "Score for incorrect answers" msgstr "Puntuació per a les respostes incorrectes" -#: eyegrade/qtgui/wizards.py:548 +#: eyegrade/qtgui/wizards.py:560 msgid "Score for blank answers" msgstr "Puntuació per a les respostes en blanc" -#: eyegrade/qtgui/wizards.py:551 +#: eyegrade/qtgui/wizards.py:563 msgid "Reset question weights" msgstr "" -#: eyegrade/qtgui/wizards.py:554 +#: eyegrade/qtgui/wizards.py:566 msgid "Per-question score weights:" msgstr "" -#: eyegrade/qtgui/wizards.py:704 +#: eyegrade/qtgui/wizards.py:716 msgid "Automatic scores cannot be computed for this exam." msgstr "No es poden calcular puntuacions per defecte per aquest examen." -#: eyegrade/qtgui/wizards.py:739 +#: eyegrade/qtgui/wizards.py:751 msgid "The score for incorrect and blank answers cannot be greater than 0." msgstr "" "La puntuació de les respostes incorrectes i en blanc no pot ser major que 0." -#: eyegrade/qtgui/wizards.py:743 +#: eyegrade/qtgui/wizards.py:755 #, fuzzy msgid "You must enter the score for correct answers." msgstr "Puntuació per a les respostes correctes" -#: eyegrade/qtgui/wizards.py:755 +#: eyegrade/qtgui/wizards.py:767 msgid "" "The weights must be the same in all the models, although they may be in a " "different order. You must fix this before computing default scores." msgstr "" -#: eyegrade/qtgui/wizards.py:763 +#: eyegrade/qtgui/wizards.py:775 #, fuzzy msgid "" "The changes you have done to the weights table will be lost. Are you sure " @@ -933,15 +941,15 @@ msgstr "" "La captura actual no ha estat guardada i es perdrà. Està segur de que vol " "tancar la sessió?" -#: eyegrade/qtgui/wizards.py:791 +#: eyegrade/qtgui/wizards.py:803 msgid "Create a new session" msgstr "Crear una nova sessió" -#: eyegrade/qtgui/wizards.py:846 +#: eyegrade/qtgui/wizards.py:858 msgid "Student id files" msgstr "Llistes d'estudiants" -#: eyegrade/qtgui/wizards.py:847 +#: eyegrade/qtgui/wizards.py:859 msgid "" "You can select zero, one or more files with the list of student ids. Go to " "the user manual if you don't know the format of the files." @@ -949,11 +957,11 @@ msgstr "" "Pot seleccionar cap, un o més fitxers d'estudiants. El manual d'usuari " "explica quin format han de tenir." -#: eyegrade/qtgui/wizards.py:851 +#: eyegrade/qtgui/wizards.py:863 msgid "Select student list files" msgstr "Selecciona els fitxers d'estudiants" -#: eyegrade/qtgui/wizards.py:869 +#: eyegrade/qtgui/wizards.py:881 msgid "Error in student list" msgstr "Error en la llista d'estudiants" diff --git a/eyegrade/data/locale/es/LC_MESSAGES/eyegrade.mo b/eyegrade/data/locale/es/LC_MESSAGES/eyegrade.mo index c6419be4..680813e1 100644 Binary files a/eyegrade/data/locale/es/LC_MESSAGES/eyegrade.mo and b/eyegrade/data/locale/es/LC_MESSAGES/eyegrade.mo differ diff --git a/eyegrade/data/locale/es/LC_MESSAGES/eyegrade.po b/eyegrade/data/locale/es/LC_MESSAGES/eyegrade.po index a52914cd..993342b4 100644 --- a/eyegrade/data/locale/es/LC_MESSAGES/eyegrade.po +++ b/eyegrade/data/locale/es/LC_MESSAGES/eyegrade.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: eyegrade\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2015-06-18 19:18+0200\n" +"POT-Creation-Date: 2017-03-10 00:13+0100\n" "PO-Revision-Date: 2015-03-17 11:04+0100\n" "Last-Translator: Jesus Arias Fisteus\n" "Language-Team: \n" @@ -46,32 +46,32 @@ msgstr "" "Formato de sesión incompatible. Esto es {0} version {1} pero la sesión fue " "creada con la versión {2}" -#: eyegrade/eyegrade.py:232 +#: eyegrade/eyegrade.py:209 msgid "The directory has no Eyegrade session" msgstr "No hay ninguna sesión Eyegrade en el directorio" -#: eyegrade/eyegrade.py:234 +#: eyegrade/eyegrade.py:211 msgid "Error opening the session file" msgstr "Error al abrir el fichero de sesión" -#: eyegrade/eyegrade.py:424 +#: eyegrade/eyegrade.py:403 #, python-brace-format msgid "There are no solutions for model {0}." msgstr "No hay soluciones para el modelo {0}." -#: eyegrade/eyegrade.py:443 +#: eyegrade/eyegrade.py:422 msgid "Input/output error:" msgstr "Error de entrada/salida" -#: eyegrade/eyegrade.py:446 +#: eyegrade/eyegrade.py:425 msgid "Error:" msgstr "Error:" -#: eyegrade/eyegrade.py:484 eyegrade/eyegrade.py:489 +#: eyegrade/eyegrade.py:463 eyegrade/eyegrade.py:468 msgid "Error loading the session" msgstr "Error al cargar la sesión" -#: eyegrade/eyegrade.py:496 +#: eyegrade/eyegrade.py:475 msgid "" "The current capture has not been saved and will be lost. Are you sure you " "want to close this session?" @@ -79,7 +79,7 @@ msgstr "" "La captura actual no ha sido guardada y se perderá. ¿Seguro que quiere " "cerrar la sesión?" -#: eyegrade/eyegrade.py:514 +#: eyegrade/eyegrade.py:493 msgid "" "The current capture has not been saved and will be lost. Are you sure you " "want to exit the application?" @@ -87,28 +87,28 @@ msgstr "" "La captura actual no ha sido guardada y se perderá. ¿Seguro que quiere salir " "del programa?" -#: eyegrade/eyegrade.py:571 +#: eyegrade/eyegrade.py:550 msgid "The selected exam will be removed. Are you sure?" msgstr "Se va a borrar el examen seleccionado. ¿Está seguro?" -#: eyegrade/eyegrade.py:663 eyegrade/eyegrade.py:676 +#: eyegrade/eyegrade.py:642 eyegrade/eyegrade.py:655 #, python-brace-format msgid "Input/output error: {0}" msgstr "Error de entrada/salida: {0}" -#: eyegrade/eyegrade.py:666 eyegrade/eyegrade.py:679 +#: eyegrade/eyegrade.py:645 eyegrade/eyegrade.py:658 msgid "The file has been saved." msgstr "El fichero ha sido guardado" -#: eyegrade/eyegrade.py:667 eyegrade/eyegrade.py:680 +#: eyegrade/eyegrade.py:646 eyegrade/eyegrade.py:659 msgid "File saved" msgstr "Fichero guardado" -#: eyegrade/eyegrade.py:720 +#: eyegrade/eyegrade.py:701 msgid "Manual detection failed" msgstr "Ha fallado la detección manual" -#: eyegrade/eyegrade.py:759 +#: eyegrade/eyegrade.py:741 msgid "No camera found. Connect a camera and start the session again." msgstr "" "No se ha encontrado ninguna cámara. Conecte una y abra de nuevo la sesión." @@ -254,48 +254,48 @@ msgstr "Modelo:" msgid "Num.:" msgstr "Núm.:" -#: eyegrade/qtgui/gui.py:579 +#: eyegrade/qtgui/gui.py:582 msgid "Grading - Scanning exam" msgstr "Corrigiendo - Escaneando un nuevo examen" -#: eyegrade/qtgui/gui.py:584 +#: eyegrade/qtgui/gui.py:587 msgid "Grading - Reviewing exam" msgstr "Corrigiendo - Revisión de la corrección" -#: eyegrade/qtgui/gui.py:587 +#: eyegrade/qtgui/gui.py:590 msgid "Session open - Reviewing exam" msgstr "Sesión abierta - Revisión de la corrección" -#: eyegrade/qtgui/gui.py:591 +#: eyegrade/qtgui/gui.py:594 msgid "Click on the outer corners of the answer tables" msgstr "Pinche en las esquinas de las tablas de respuestas" -#: eyegrade/qtgui/gui.py:593 +#: eyegrade/qtgui/gui.py:596 msgid "Grading - Manual detection mode" msgstr "Corrigiendo - Modo de detección manual" -#: eyegrade/qtgui/gui.py:599 +#: eyegrade/qtgui/gui.py:603 msgid "Session open" msgstr "Sesión abierta" -#: eyegrade/qtgui/gui.py:605 +#: eyegrade/qtgui/gui.py:609 msgid "No session: open or create a session to start" msgstr "No hay sesión abierta: abra o cree una sesión para comenzar" -#: eyegrade/qtgui/gui.py:762 +#: eyegrade/qtgui/gui.py:766 msgid "Select the session file" msgstr "Seleccione el fichero de sesión" -#: eyegrade/qtgui/gui.py:799 +#: eyegrade/qtgui/gui.py:803 msgid "Save exam configration as..." msgstr "Guardar la configuración de examen como..." -#: eyegrade/qtgui/gui.py:800 eyegrade/qtgui/__init__.py:31 +#: eyegrade/qtgui/gui.py:804 eyegrade/qtgui/__init__.py:31 msgid "Exam configuration (*.eye)" msgstr "Configuración de examen (*.eye)" -#: eyegrade/qtgui/gui.py:835 eyegrade/qtgui/gui.py:841 -#: eyegrade/qtgui/wizards.py:762 +#: eyegrade/qtgui/gui.py:839 eyegrade/qtgui/gui.py:845 +#: eyegrade/qtgui/wizards.py:774 msgid "Warning" msgstr "Aviso" @@ -415,115 +415,115 @@ msgstr "Nuevo estudiante" msgid "Student id:" msgstr "Estudiante" -#: eyegrade/qtgui/dialogs.py:111 +#: eyegrade/qtgui/dialogs.py:112 msgid "Add a new student" msgstr "Añadir un nuevo estudiante" -#: eyegrade/qtgui/dialogs.py:120 +#: eyegrade/qtgui/dialogs.py:121 msgid "Given name" msgstr "Nombre" -#: eyegrade/qtgui/dialogs.py:121 +#: eyegrade/qtgui/dialogs.py:122 msgid "Surname" msgstr "Apellidos" -#: eyegrade/qtgui/dialogs.py:122 +#: eyegrade/qtgui/dialogs.py:123 msgid "Full name" msgstr "Nombre y apellidos" -#: eyegrade/qtgui/dialogs.py:128 +#: eyegrade/qtgui/dialogs.py:129 msgid "Separate given name and surname" msgstr "Nombre y apellidos por separado" -#: eyegrade/qtgui/dialogs.py:129 +#: eyegrade/qtgui/dialogs.py:130 msgid "Full name in just one field" msgstr "Nombre completo en un solo campo" -#: eyegrade/qtgui/dialogs.py:132 +#: eyegrade/qtgui/dialogs.py:133 msgid "Id number" msgstr "Número de identificación" -#: eyegrade/qtgui/dialogs.py:136 +#: eyegrade/qtgui/dialogs.py:137 msgid "Email" msgstr "Correo electrónico" -#: eyegrade/qtgui/dialogs.py:219 eyegrade/qtgui/wizards.py:552 +#: eyegrade/qtgui/dialogs.py:220 eyegrade/qtgui/wizards.py:564 msgid "Compute default scores" msgstr "Calcular notas por defecto" -#: eyegrade/qtgui/dialogs.py:223 +#: eyegrade/qtgui/dialogs.py:224 msgid "Penalize incorrect answers" msgstr "Penalizar respuestas incorrectas" -#: eyegrade/qtgui/dialogs.py:226 +#: eyegrade/qtgui/dialogs.py:227 msgid "Maximum score" msgstr "Nota máxima" -#: eyegrade/qtgui/dialogs.py:227 +#: eyegrade/qtgui/dialogs.py:228 msgid "Penalizations" msgstr "Penalizaciones" -#: eyegrade/qtgui/dialogs.py:251 eyegrade/qtgui/widgets.py:230 -#: eyegrade/qtgui/wizards.py:315 eyegrade/qtgui/wizards.py:508 -#: eyegrade/qtgui/wizards.py:703 eyegrade/qtgui/wizards.py:738 -#: eyegrade/qtgui/wizards.py:742 eyegrade/qtgui/wizards.py:754 +#: eyegrade/qtgui/dialogs.py:252 eyegrade/qtgui/widgets.py:242 +#: eyegrade/qtgui/wizards.py:327 eyegrade/qtgui/wizards.py:520 +#: eyegrade/qtgui/wizards.py:715 eyegrade/qtgui/wizards.py:750 +#: eyegrade/qtgui/wizards.py:754 eyegrade/qtgui/wizards.py:766 msgid "Error" msgstr "Error" -#: eyegrade/qtgui/dialogs.py:252 +#: eyegrade/qtgui/dialogs.py:253 msgid "Enter a valid score." msgstr "Introduzca una nota válida" -#: eyegrade/qtgui/dialogs.py:283 +#: eyegrade/qtgui/dialogs.py:284 msgid "Select a camera" msgstr "Seleccionar cámara" -#: eyegrade/qtgui/dialogs.py:288 +#: eyegrade/qtgui/dialogs.py:289 msgid "Try this camera" msgstr "Probar esta cámara" -#: eyegrade/qtgui/dialogs.py:322 eyegrade/qtgui/dialogs.py:337 +#: eyegrade/qtgui/dialogs.py:323 eyegrade/qtgui/dialogs.py:338 msgid "Camera not available" msgstr "Cámara no disponible" -#: eyegrade/qtgui/dialogs.py:323 +#: eyegrade/qtgui/dialogs.py:324 msgid "Eyegrade has not detected any camera in your system." msgstr "Eyegrade no ha detectado ninguna cámara." -#: eyegrade/qtgui/dialogs.py:338 +#: eyegrade/qtgui/dialogs.py:339 #, python-brace-format msgid "Camera {0} is not available." msgstr "La cámara {0} no está disponible." -#: eyegrade/qtgui/dialogs.py:343 +#: eyegrade/qtgui/dialogs.py:344 #, python-brace-format msgid "
Viewing camera: {0}
" msgstr "
Viendo cámara: {0}
" -#: eyegrade/qtgui/dialogs.py:347 +#: eyegrade/qtgui/dialogs.py:348 msgid "
No camera
" msgstr "
No hay cámara
" -#: eyegrade/qtgui/dialogs.py:371 eyegrade/qtgui/dialogs.py:378 +#: eyegrade/qtgui/dialogs.py:372 eyegrade/qtgui/dialogs.py:379 msgid "About" msgstr "Acerca de" -#: eyegrade/qtgui/dialogs.py:379 +#: eyegrade/qtgui/dialogs.py:380 msgid "Developers" msgstr "Desarrolladores" -#: eyegrade/qtgui/dialogs.py:380 +#: eyegrade/qtgui/dialogs.py:381 msgid "Translators" msgstr "Traductores" -#: eyegrade/qtgui/dialogs.py:386 +#: eyegrade/qtgui/dialogs.py:387 #, python-brace-format msgid "" "\n" "
\n" "


\n" " {1} {2}
\n" -" (c) 2010-2015 Jesús Arias Fisteus
\n" +" (c) 2010-2017 Jesús Arias Fisteus and contributors
\n" " {3}
\n" " {4}\n" "\n" @@ -554,7 +554,7 @@ msgstr "" "

\n" "


\n" " {1} {2}
\n" -" (c) 2010-2015 Jesus Arias Fisteus
\n" +" (c) 2010-2017 Jesus Arias Fisteus
\n" " {3}
\n" " {4}\n" "\n" @@ -580,63 +580,71 @@ msgstr "" "

\n" " " -#: eyegrade/qtgui/dialogs.py:434 +#: eyegrade/qtgui/dialogs.py:439 msgid "Lead developers" msgstr "Desarrolladores principales" -#: eyegrade/qtgui/dialogs.py:435 +#: eyegrade/qtgui/dialogs.py:440 msgid "Exam configuration dialogs" msgstr "Diálogos de configuración de examen" -#: eyegrade/qtgui/dialogs.py:448 +#: eyegrade/qtgui/dialogs.py:441 +msgid "Manuscript digits recognition" +msgstr "Reconocimiento de dígitos manuscritos" + +#: eyegrade/qtgui/dialogs.py:442 +msgid "Testing and other contributions" +msgstr "Pruebas y otras contribuciones" + +#: eyegrade/qtgui/dialogs.py:455 msgid "Catalan" msgstr "Catalán" -#: eyegrade/qtgui/dialogs.py:449 +#: eyegrade/qtgui/dialogs.py:456 msgid "German" msgstr "Alemán" -#: eyegrade/qtgui/dialogs.py:450 +#: eyegrade/qtgui/dialogs.py:457 msgid "Galician" msgstr "Gallego" -#: eyegrade/qtgui/dialogs.py:451 +#: eyegrade/qtgui/dialogs.py:458 msgid "French" msgstr "Francés" -#: eyegrade/qtgui/dialogs.py:452 +#: eyegrade/qtgui/dialogs.py:459 msgid "Portuguese" msgstr "Portugués" -#: eyegrade/qtgui/dialogs.py:453 +#: eyegrade/qtgui/dialogs.py:460 msgid "Spanish" msgstr "Español" -#: eyegrade/qtgui/widgets.py:259 +#: eyegrade/qtgui/widgets.py:271 msgid "e.g.: 2; 2.5; 5/2" msgstr "ejemplos: 2; 2.5; 5/2" -#: eyegrade/qtgui/widgets.py:261 +#: eyegrade/qtgui/widgets.py:273 msgid "e.g.: 0; -1; -1.25; -5/4" msgstr "ejemplos: 0; -1; -1.25; -5/4" -#: eyegrade/qtgui/widgets.py:333 +#: eyegrade/qtgui/widgets.py:345 msgid "Add files" msgstr "Añadir ficheros" -#: eyegrade/qtgui/widgets.py:334 +#: eyegrade/qtgui/widgets.py:346 msgid "Remove selected" msgstr "Eliminar seleccionados" -#: eyegrade/qtgui/widgets.py:630 +#: eyegrade/qtgui/widgets.py:642 msgid "Model" msgstr "Modelo" -#: eyegrade/qtgui/widgets.py:633 +#: eyegrade/qtgui/widgets.py:645 msgid "Question" msgstr "Pregunta" -#: eyegrade/qtgui/widgets.py:635 +#: eyegrade/qtgui/widgets.py:647 msgid "Total" msgstr "Total" @@ -693,11 +701,11 @@ msgstr "El fichero de configuración de examen no existe." msgid "The exam configuration file is not a regular file." msgstr "El fichero de configuración de examen no es un fichero regular." -#: eyegrade/qtgui/wizards.py:129 eyegrade/qtgui/wizards.py:306 +#: eyegrade/qtgui/wizards.py:129 eyegrade/qtgui/wizards.py:318 msgid "The exam configuration file cannot be read." msgstr "No se puede leer el fichero de configuración de examen." -#: eyegrade/qtgui/wizards.py:132 eyegrade/qtgui/wizards.py:309 +#: eyegrade/qtgui/wizards.py:132 eyegrade/qtgui/wizards.py:321 msgid "The exam configuration file contains errors" msgstr "El fichero de configuración de examen contiene errores." @@ -762,69 +770,69 @@ msgstr "Selección de las respuestas correctas" msgid "Select the correct answers for each exam model" msgstr "Seleccione las respuestas correctas de cada modelo de examen" -#: eyegrade/qtgui/wizards.py:263 eyegrade/qtgui/wizards.py:370 +#: eyegrade/qtgui/wizards.py:275 eyegrade/qtgui/wizards.py:382 msgid "Model " msgstr "Modelo " -#: eyegrade/qtgui/wizards.py:287 +#: eyegrade/qtgui/wizards.py:299 msgid "You haven't entered the correct answer for some questions." msgstr "No ha marcado la respuesta de algunas preguntas." -#: eyegrade/qtgui/wizards.py:332 +#: eyegrade/qtgui/wizards.py:344 msgid "Configuration of permutations" msgstr "" -#: eyegrade/qtgui/wizards.py:333 +#: eyegrade/qtgui/wizards.py:345 msgid "" "Select the position of each question and its choices in every model of the " "exam." msgstr "" -#: eyegrade/qtgui/wizards.py:339 +#: eyegrade/qtgui/wizards.py:351 msgid "Questions of model A" msgstr "" -#: eyegrade/qtgui/wizards.py:341 +#: eyegrade/qtgui/wizards.py:353 msgid "Model equivalence" msgstr "" -#: eyegrade/qtgui/wizards.py:359 eyegrade/qtgui/wizards.py:361 +#: eyegrade/qtgui/wizards.py:371 eyegrade/qtgui/wizards.py:373 msgid "Question " msgstr "" -#: eyegrade/qtgui/wizards.py:396 +#: eyegrade/qtgui/wizards.py:408 msgid "Question Number" msgstr "" -#: eyegrade/qtgui/wizards.py:400 +#: eyegrade/qtgui/wizards.py:412 msgid "Save values" msgstr "" -#: eyegrade/qtgui/wizards.py:426 +#: eyegrade/qtgui/wizards.py:438 msgid "Information status" msgstr "" -#: eyegrade/qtgui/wizards.py:427 +#: eyegrade/qtgui/wizards.py:439 msgid "The values for the question have been successfully saved" msgstr "" -#: eyegrade/qtgui/wizards.py:430 +#: eyegrade/qtgui/wizards.py:442 msgid "Error in grid" msgstr "" -#: eyegrade/qtgui/wizards.py:431 +#: eyegrade/qtgui/wizards.py:443 msgid "There is an inconsistence in the options" msgstr "" -#: eyegrade/qtgui/wizards.py:505 +#: eyegrade/qtgui/wizards.py:517 msgid "You must select all permutations for all questions" msgstr "" -#: eyegrade/qtgui/wizards.py:523 +#: eyegrade/qtgui/wizards.py:535 msgid "Scores for correct and incorrect answers" msgstr "Notas para respuestas correctas e incorrectas" -#: eyegrade/qtgui/wizards.py:524 +#: eyegrade/qtgui/wizards.py:536 msgid "" "Enter the scores of correct and incorrect answers. The program will compute " "scores based on them. Setting these scores is optional." @@ -832,52 +840,52 @@ msgstr "" "Introduzca las puntuaciones de las respuestas correctas e incorrectas con " "las que se calcularán las notas. Este paso es opcional." -#: eyegrade/qtgui/wizards.py:540 +#: eyegrade/qtgui/wizards.py:552 msgid "No scores" msgstr "Sin notas" -#: eyegrade/qtgui/wizards.py:541 +#: eyegrade/qtgui/wizards.py:553 msgid "Same score for all the questions" msgstr "Misma nota para todas las preguntas" -#: eyegrade/qtgui/wizards.py:542 +#: eyegrade/qtgui/wizards.py:554 msgid "Base score plus per-question weight" msgstr "Nota base y un peso por pregunta" -#: eyegrade/qtgui/wizards.py:546 +#: eyegrade/qtgui/wizards.py:558 msgid "Score for correct answers" msgstr "Nota para respuestas correctas" -#: eyegrade/qtgui/wizards.py:547 +#: eyegrade/qtgui/wizards.py:559 msgid "Score for incorrect answers" msgstr "Nota para respuestas incorrectas" -#: eyegrade/qtgui/wizards.py:548 +#: eyegrade/qtgui/wizards.py:560 msgid "Score for blank answers" msgstr "Nota para respuestas en blanco" -#: eyegrade/qtgui/wizards.py:551 +#: eyegrade/qtgui/wizards.py:563 msgid "Reset question weights" msgstr "Restablecer los pesos de las preguntas" -#: eyegrade/qtgui/wizards.py:554 +#: eyegrade/qtgui/wizards.py:566 msgid "Per-question score weights:" msgstr "Pesos por pregunta:" -#: eyegrade/qtgui/wizards.py:704 +#: eyegrade/qtgui/wizards.py:716 msgid "Automatic scores cannot be computed for this exam." msgstr "No se pueden calcular notas automáticamente para este examen." -#: eyegrade/qtgui/wizards.py:739 +#: eyegrade/qtgui/wizards.py:751 msgid "The score for incorrect and blank answers cannot be greater than 0." msgstr "" "La nota de las respuestas incorrectas y en blanco no puede ser mayor que 0." -#: eyegrade/qtgui/wizards.py:743 +#: eyegrade/qtgui/wizards.py:755 msgid "You must enter the score for correct answers." msgstr "Debe introducir la puntuación de las respuestas correctas." -#: eyegrade/qtgui/wizards.py:755 +#: eyegrade/qtgui/wizards.py:767 msgid "" "The weights must be the same in all the models, although they may be in a " "different order. You must fix this before computing default scores." @@ -886,7 +894,7 @@ msgstr "" "distinto orden. Debe arreglar este problema antes de calcular las notas por " "defecto." -#: eyegrade/qtgui/wizards.py:763 +#: eyegrade/qtgui/wizards.py:775 msgid "" "The changes you have done to the weights table will be lost. Are you sure " "you want to continue?" @@ -894,15 +902,15 @@ msgstr "" "Se perderán los cambios realizados en la tabla de pesos. ¿Seguro que quiere " "continuar?" -#: eyegrade/qtgui/wizards.py:791 +#: eyegrade/qtgui/wizards.py:803 msgid "Create a new session" msgstr "Crear nueva sesión" -#: eyegrade/qtgui/wizards.py:846 +#: eyegrade/qtgui/wizards.py:858 msgid "Student id files" msgstr "Listas de estudiantes" -#: eyegrade/qtgui/wizards.py:847 +#: eyegrade/qtgui/wizards.py:859 msgid "" "You can select zero, one or more files with the list of student ids. Go to " "the user manual if you don't know the format of the files." @@ -910,11 +918,11 @@ msgstr "" "Puede seleccionar ninguno, uno o más ficheros de estudiantes. El manual de " "usuario explica el formato que deben tener." -#: eyegrade/qtgui/wizards.py:851 +#: eyegrade/qtgui/wizards.py:863 msgid "Select student list files" msgstr "Seleccionar fichero de estudiantes" -#: eyegrade/qtgui/wizards.py:869 +#: eyegrade/qtgui/wizards.py:881 msgid "Error in student list" msgstr "Error en la lista de estudiantes" diff --git a/eyegrade/data/locale/gl/LC_MESSAGES/eyegrade.mo b/eyegrade/data/locale/gl/LC_MESSAGES/eyegrade.mo index 24a8d2cb..5db2745b 100644 Binary files a/eyegrade/data/locale/gl/LC_MESSAGES/eyegrade.mo and b/eyegrade/data/locale/gl/LC_MESSAGES/eyegrade.mo differ diff --git a/eyegrade/data/locale/gl/LC_MESSAGES/eyegrade.po b/eyegrade/data/locale/gl/LC_MESSAGES/eyegrade.po index fb78076d..b61b66b9 100644 --- a/eyegrade/data/locale/gl/LC_MESSAGES/eyegrade.po +++ b/eyegrade/data/locale/gl/LC_MESSAGES/eyegrade.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: eyegrade\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2015-06-18 19:18+0200\n" +"POT-Creation-Date: 2017-03-10 00:13+0100\n" "PO-Revision-Date: 2015-03-17 11:04+0100\n" "Last-Translator: Jesús Arias Fisteus\n" "Language-Team: \n" @@ -46,69 +46,69 @@ msgstr "" "Formato de sesión incompatible. Esto é {0} version {1} pero a sesión foi " "creada coa versión {2}" -#: eyegrade/eyegrade.py:232 +#: eyegrade/eyegrade.py:209 msgid "The directory has no Eyegrade session" msgstr "Non hay ningunha sesión Eyegrade no directorio" -#: eyegrade/eyegrade.py:234 +#: eyegrade/eyegrade.py:211 msgid "Error opening the session file" msgstr "Erro ó abri-lo ficheiro de sesión" -#: eyegrade/eyegrade.py:424 +#: eyegrade/eyegrade.py:403 #, python-brace-format msgid "There are no solutions for model {0}." msgstr "Non hay solucións para o modelo {0}." -#: eyegrade/eyegrade.py:443 +#: eyegrade/eyegrade.py:422 msgid "Input/output error:" msgstr "Erro de entrada/saída" -#: eyegrade/eyegrade.py:446 +#: eyegrade/eyegrade.py:425 msgid "Error:" msgstr "Erro:" -#: eyegrade/eyegrade.py:484 eyegrade/eyegrade.py:489 +#: eyegrade/eyegrade.py:463 eyegrade/eyegrade.py:468 msgid "Error loading the session" msgstr "Erro ó carga-la sesión" -#: eyegrade/eyegrade.py:496 +#: eyegrade/eyegrade.py:475 msgid "" "The current capture has not been saved and will be lost. Are you sure you " "want to close this session?" msgstr "" -"A captura actual non foi gardada e vaise perder. ¿Seguro que quere cerra-la " +"A captura actual non foi gardada e vaise perder. Seguro que quere cerra-la " "sesión?" -#: eyegrade/eyegrade.py:514 +#: eyegrade/eyegrade.py:493 msgid "" "The current capture has not been saved and will be lost. Are you sure you " "want to exit the application?" msgstr "" -"A captura actual non foi gardada e vaise perder. ¿Seguro que quiere saír do " +"A captura actual non foi gardada e vaise perder. Seguro que quiere saír do " "programa?" -#: eyegrade/eyegrade.py:571 +#: eyegrade/eyegrade.py:550 msgid "The selected exam will be removed. Are you sure?" -msgstr "Vaise borrar o exame seleccionado. ¿Está seguro?" +msgstr "Vaise borrar o exame seleccionado. Está seguro?" -#: eyegrade/eyegrade.py:663 eyegrade/eyegrade.py:676 +#: eyegrade/eyegrade.py:642 eyegrade/eyegrade.py:655 #, python-brace-format msgid "Input/output error: {0}" msgstr "Erro de entrada/saída: {0}" -#: eyegrade/eyegrade.py:666 eyegrade/eyegrade.py:679 +#: eyegrade/eyegrade.py:645 eyegrade/eyegrade.py:658 msgid "The file has been saved." msgstr "O ficheiro foi gardado." -#: eyegrade/eyegrade.py:667 eyegrade/eyegrade.py:680 +#: eyegrade/eyegrade.py:646 eyegrade/eyegrade.py:659 msgid "File saved" msgstr "Ficheiro gardado" -#: eyegrade/eyegrade.py:720 +#: eyegrade/eyegrade.py:701 msgid "Manual detection failed" msgstr "A detección manual fallou" -#: eyegrade/eyegrade.py:759 +#: eyegrade/eyegrade.py:741 msgid "No camera found. Connect a camera and start the session again." msgstr "Non se atopou ningunha cámara. Conecte unha e abra de novo a sesión." @@ -253,48 +253,48 @@ msgstr "Modelo:" msgid "Num.:" msgstr "Núm.:" -#: eyegrade/qtgui/gui.py:579 +#: eyegrade/qtgui/gui.py:582 msgid "Grading - Scanning exam" msgstr "Corrixindo - Escaneando o seguinte exame" -#: eyegrade/qtgui/gui.py:584 +#: eyegrade/qtgui/gui.py:587 msgid "Grading - Reviewing exam" msgstr "Corrixindo - Revisa-la corrección" -#: eyegrade/qtgui/gui.py:587 +#: eyegrade/qtgui/gui.py:590 msgid "Session open - Reviewing exam" msgstr "Sesión aberta - Revisa-la corrección" -#: eyegrade/qtgui/gui.py:591 +#: eyegrade/qtgui/gui.py:594 msgid "Click on the outer corners of the answer tables" msgstr "Pinche nas esquinas das táboas de respostas" -#: eyegrade/qtgui/gui.py:593 +#: eyegrade/qtgui/gui.py:596 msgid "Grading - Manual detection mode" msgstr "Corrixindo - Modo de detección manual" -#: eyegrade/qtgui/gui.py:599 +#: eyegrade/qtgui/gui.py:603 msgid "Session open" msgstr "Sesión aberta" -#: eyegrade/qtgui/gui.py:605 +#: eyegrade/qtgui/gui.py:609 msgid "No session: open or create a session to start" msgstr "Non hay sesión aberta: abra ou cree unha sesión para comezar" -#: eyegrade/qtgui/gui.py:762 +#: eyegrade/qtgui/gui.py:766 msgid "Select the session file" msgstr "Seleccione o ficheiro de sesión" -#: eyegrade/qtgui/gui.py:799 +#: eyegrade/qtgui/gui.py:803 msgid "Save exam configration as..." msgstr "Garda-la configuración de exame como..." -#: eyegrade/qtgui/gui.py:800 eyegrade/qtgui/__init__.py:31 +#: eyegrade/qtgui/gui.py:804 eyegrade/qtgui/__init__.py:31 msgid "Exam configuration (*.eye)" msgstr "Configuración de exame (*.eye)" -#: eyegrade/qtgui/gui.py:835 eyegrade/qtgui/gui.py:841 -#: eyegrade/qtgui/wizards.py:762 +#: eyegrade/qtgui/gui.py:839 eyegrade/qtgui/gui.py:845 +#: eyegrade/qtgui/wizards.py:774 msgid "Warning" msgstr "Aviso" @@ -414,115 +414,115 @@ msgstr "Novo estudante" msgid "Student id:" msgstr "Estudante" -#: eyegrade/qtgui/dialogs.py:111 +#: eyegrade/qtgui/dialogs.py:112 msgid "Add a new student" msgstr "Engadir un novo estudante" -#: eyegrade/qtgui/dialogs.py:120 +#: eyegrade/qtgui/dialogs.py:121 msgid "Given name" msgstr "Nome" -#: eyegrade/qtgui/dialogs.py:121 +#: eyegrade/qtgui/dialogs.py:122 msgid "Surname" msgstr "Apelidos" -#: eyegrade/qtgui/dialogs.py:122 +#: eyegrade/qtgui/dialogs.py:123 msgid "Full name" msgstr "Nome e apelidos" -#: eyegrade/qtgui/dialogs.py:128 +#: eyegrade/qtgui/dialogs.py:129 msgid "Separate given name and surname" msgstr "Nome e apelidos por separado" -#: eyegrade/qtgui/dialogs.py:129 +#: eyegrade/qtgui/dialogs.py:130 msgid "Full name in just one field" msgstr "Nome e apelidos nun só campo" -#: eyegrade/qtgui/dialogs.py:132 +#: eyegrade/qtgui/dialogs.py:133 msgid "Id number" msgstr "Número de identificación" -#: eyegrade/qtgui/dialogs.py:136 +#: eyegrade/qtgui/dialogs.py:137 msgid "Email" msgstr "Correo electrónico" -#: eyegrade/qtgui/dialogs.py:219 eyegrade/qtgui/wizards.py:552 +#: eyegrade/qtgui/dialogs.py:220 eyegrade/qtgui/wizards.py:564 msgid "Compute default scores" msgstr "Calcular notas por defecto" -#: eyegrade/qtgui/dialogs.py:223 +#: eyegrade/qtgui/dialogs.py:224 msgid "Penalize incorrect answers" msgstr "Penalizar respostas incorrectas" -#: eyegrade/qtgui/dialogs.py:226 +#: eyegrade/qtgui/dialogs.py:227 msgid "Maximum score" msgstr "Nota máxima" -#: eyegrade/qtgui/dialogs.py:227 +#: eyegrade/qtgui/dialogs.py:228 msgid "Penalizations" msgstr "Penalizacións" -#: eyegrade/qtgui/dialogs.py:251 eyegrade/qtgui/widgets.py:230 -#: eyegrade/qtgui/wizards.py:315 eyegrade/qtgui/wizards.py:508 -#: eyegrade/qtgui/wizards.py:703 eyegrade/qtgui/wizards.py:738 -#: eyegrade/qtgui/wizards.py:742 eyegrade/qtgui/wizards.py:754 +#: eyegrade/qtgui/dialogs.py:252 eyegrade/qtgui/widgets.py:242 +#: eyegrade/qtgui/wizards.py:327 eyegrade/qtgui/wizards.py:520 +#: eyegrade/qtgui/wizards.py:715 eyegrade/qtgui/wizards.py:750 +#: eyegrade/qtgui/wizards.py:754 eyegrade/qtgui/wizards.py:766 msgid "Error" msgstr "Erro" -#: eyegrade/qtgui/dialogs.py:252 +#: eyegrade/qtgui/dialogs.py:253 msgid "Enter a valid score." msgstr "Introduza unha nota válida" -#: eyegrade/qtgui/dialogs.py:283 +#: eyegrade/qtgui/dialogs.py:284 msgid "Select a camera" msgstr "Seleccionar cámara" -#: eyegrade/qtgui/dialogs.py:288 +#: eyegrade/qtgui/dialogs.py:289 msgid "Try this camera" msgstr "Probar esta cámara" -#: eyegrade/qtgui/dialogs.py:322 eyegrade/qtgui/dialogs.py:337 +#: eyegrade/qtgui/dialogs.py:323 eyegrade/qtgui/dialogs.py:338 msgid "Camera not available" msgstr "Cámara non dispoñible" -#: eyegrade/qtgui/dialogs.py:323 +#: eyegrade/qtgui/dialogs.py:324 msgid "Eyegrade has not detected any camera in your system." msgstr "Eyegrade non detectou ningunha cámara." -#: eyegrade/qtgui/dialogs.py:338 +#: eyegrade/qtgui/dialogs.py:339 #, python-brace-format msgid "Camera {0} is not available." msgstr "A cámara {0} non está dispoñible." -#: eyegrade/qtgui/dialogs.py:343 +#: eyegrade/qtgui/dialogs.py:344 #, python-brace-format msgid "
Viewing camera: {0}
" msgstr "
Vendo cámara: {0}
" -#: eyegrade/qtgui/dialogs.py:347 +#: eyegrade/qtgui/dialogs.py:348 msgid "
No camera
" msgstr "
Non hay cámara
" -#: eyegrade/qtgui/dialogs.py:371 eyegrade/qtgui/dialogs.py:378 +#: eyegrade/qtgui/dialogs.py:372 eyegrade/qtgui/dialogs.py:379 msgid "About" msgstr "Acerca de" -#: eyegrade/qtgui/dialogs.py:379 +#: eyegrade/qtgui/dialogs.py:380 msgid "Developers" msgstr "Desenvolvedores" -#: eyegrade/qtgui/dialogs.py:380 +#: eyegrade/qtgui/dialogs.py:381 msgid "Translators" msgstr "Tradutores" -#: eyegrade/qtgui/dialogs.py:386 +#: eyegrade/qtgui/dialogs.py:387 #, python-brace-format msgid "" "\n" "
\n" "


\n" " {1} {2}
\n" -" (c) 2010-2015 Jesús Arias Fisteus
\n" +" (c) 2010-2017 Jesús Arias Fisteus and contributors
\n" " {3}
\n" " {4}\n" "\n" @@ -553,7 +553,7 @@ msgstr "" "

\n" "


\n" " {1} {2}
\n" -" (c) 2010-2015 Jesus Arias Fisteus
\n" +" (c) 2010-2017 Jesus Arias Fisteus
\n" " {3}
\n" " {4}\n" "\n" @@ -578,63 +578,71 @@ msgstr "" "

\n" " " -#: eyegrade/qtgui/dialogs.py:434 +#: eyegrade/qtgui/dialogs.py:439 msgid "Lead developers" msgstr "Desenvolvedores principais" -#: eyegrade/qtgui/dialogs.py:435 +#: eyegrade/qtgui/dialogs.py:440 msgid "Exam configuration dialogs" msgstr "Diálogos de configuración de exame" -#: eyegrade/qtgui/dialogs.py:448 +#: eyegrade/qtgui/dialogs.py:441 +msgid "Manuscript digits recognition" +msgstr "Recoñecemento de díxitos manuscritos" + +#: eyegrade/qtgui/dialogs.py:442 +msgid "Testing and other contributions" +msgstr "Probas e outras contribucións" + +#: eyegrade/qtgui/dialogs.py:455 msgid "Catalan" msgstr "Catalán" -#: eyegrade/qtgui/dialogs.py:449 +#: eyegrade/qtgui/dialogs.py:456 msgid "German" msgstr "Alemán" -#: eyegrade/qtgui/dialogs.py:450 +#: eyegrade/qtgui/dialogs.py:457 msgid "Galician" msgstr "Galego" -#: eyegrade/qtgui/dialogs.py:451 +#: eyegrade/qtgui/dialogs.py:458 msgid "French" msgstr "Francés" -#: eyegrade/qtgui/dialogs.py:452 +#: eyegrade/qtgui/dialogs.py:459 msgid "Portuguese" msgstr "Portugués" -#: eyegrade/qtgui/dialogs.py:453 +#: eyegrade/qtgui/dialogs.py:460 msgid "Spanish" msgstr "Español" -#: eyegrade/qtgui/widgets.py:259 +#: eyegrade/qtgui/widgets.py:271 msgid "e.g.: 2; 2.5; 5/2" msgstr "exemplos: 2; 2.5; 5/2" -#: eyegrade/qtgui/widgets.py:261 +#: eyegrade/qtgui/widgets.py:273 msgid "e.g.: 0; -1; -1.25; -5/4" msgstr "exemplos: 0; -1; -1.25; -5/4" -#: eyegrade/qtgui/widgets.py:333 +#: eyegrade/qtgui/widgets.py:345 msgid "Add files" msgstr "Añadir ficheiros" -#: eyegrade/qtgui/widgets.py:334 +#: eyegrade/qtgui/widgets.py:346 msgid "Remove selected" msgstr "Eliminar seleccionados" -#: eyegrade/qtgui/widgets.py:630 +#: eyegrade/qtgui/widgets.py:642 msgid "Model" msgstr "Modelo" -#: eyegrade/qtgui/widgets.py:633 +#: eyegrade/qtgui/widgets.py:645 msgid "Question" msgstr "Pregunta" -#: eyegrade/qtgui/widgets.py:635 +#: eyegrade/qtgui/widgets.py:647 msgid "Total" msgstr "Total" @@ -691,11 +699,11 @@ msgstr "O ficheiro de configuración de exame non existe." msgid "The exam configuration file is not a regular file." msgstr "O ficheiro de configuración de examen non é un ficheiro regular." -#: eyegrade/qtgui/wizards.py:129 eyegrade/qtgui/wizards.py:306 +#: eyegrade/qtgui/wizards.py:129 eyegrade/qtgui/wizards.py:318 msgid "The exam configuration file cannot be read." msgstr "Non se pode lee-lo ficheiro de configuración de exame." -#: eyegrade/qtgui/wizards.py:132 eyegrade/qtgui/wizards.py:309 +#: eyegrade/qtgui/wizards.py:132 eyegrade/qtgui/wizards.py:321 msgid "The exam configuration file contains errors" msgstr "O ficheiro de configuración de exame contén erros." @@ -760,69 +768,69 @@ msgstr "Selección das respostas correctas" msgid "Select the correct answers for each exam model" msgstr "Seleccione as respostas correctas de cada modelo de exame" -#: eyegrade/qtgui/wizards.py:263 eyegrade/qtgui/wizards.py:370 +#: eyegrade/qtgui/wizards.py:275 eyegrade/qtgui/wizards.py:382 msgid "Model " msgstr "Modelo " -#: eyegrade/qtgui/wizards.py:287 +#: eyegrade/qtgui/wizards.py:299 msgid "You haven't entered the correct answer for some questions." msgstr "Non marcou a resposta dalgunhas preguntas." -#: eyegrade/qtgui/wizards.py:332 +#: eyegrade/qtgui/wizards.py:344 msgid "Configuration of permutations" msgstr "" -#: eyegrade/qtgui/wizards.py:333 +#: eyegrade/qtgui/wizards.py:345 msgid "" "Select the position of each question and its choices in every model of the " "exam." msgstr "" -#: eyegrade/qtgui/wizards.py:339 +#: eyegrade/qtgui/wizards.py:351 msgid "Questions of model A" msgstr "" -#: eyegrade/qtgui/wizards.py:341 +#: eyegrade/qtgui/wizards.py:353 msgid "Model equivalence" msgstr "" -#: eyegrade/qtgui/wizards.py:359 eyegrade/qtgui/wizards.py:361 +#: eyegrade/qtgui/wizards.py:371 eyegrade/qtgui/wizards.py:373 msgid "Question " msgstr "" -#: eyegrade/qtgui/wizards.py:396 +#: eyegrade/qtgui/wizards.py:408 msgid "Question Number" msgstr "" -#: eyegrade/qtgui/wizards.py:400 +#: eyegrade/qtgui/wizards.py:412 msgid "Save values" msgstr "" -#: eyegrade/qtgui/wizards.py:426 +#: eyegrade/qtgui/wizards.py:438 msgid "Information status" msgstr "" -#: eyegrade/qtgui/wizards.py:427 +#: eyegrade/qtgui/wizards.py:439 msgid "The values for the question have been successfully saved" msgstr "" -#: eyegrade/qtgui/wizards.py:430 +#: eyegrade/qtgui/wizards.py:442 msgid "Error in grid" msgstr "" -#: eyegrade/qtgui/wizards.py:431 +#: eyegrade/qtgui/wizards.py:443 msgid "There is an inconsistence in the options" msgstr "" -#: eyegrade/qtgui/wizards.py:505 +#: eyegrade/qtgui/wizards.py:517 msgid "You must select all permutations for all questions" msgstr "" -#: eyegrade/qtgui/wizards.py:523 +#: eyegrade/qtgui/wizards.py:535 msgid "Scores for correct and incorrect answers" msgstr "Notas para respostas correctas e incorrectas" -#: eyegrade/qtgui/wizards.py:524 +#: eyegrade/qtgui/wizards.py:536 msgid "" "Enter the scores of correct and incorrect answers. The program will compute " "scores based on them. Setting these scores is optional." @@ -830,51 +838,51 @@ msgstr "" "Introduza as puntuacións das respostas correctas e incorrectas coas que se " "calcularán as notas. Este paso é opcional." -#: eyegrade/qtgui/wizards.py:540 +#: eyegrade/qtgui/wizards.py:552 msgid "No scores" msgstr "Sen notas" -#: eyegrade/qtgui/wizards.py:541 +#: eyegrade/qtgui/wizards.py:553 msgid "Same score for all the questions" msgstr "Mesma nota para tóda-las preguntas" -#: eyegrade/qtgui/wizards.py:542 +#: eyegrade/qtgui/wizards.py:554 msgid "Base score plus per-question weight" msgstr "Nota base e un peso por pregunta" -#: eyegrade/qtgui/wizards.py:546 +#: eyegrade/qtgui/wizards.py:558 msgid "Score for correct answers" msgstr "Nota para respostas correctas" -#: eyegrade/qtgui/wizards.py:547 +#: eyegrade/qtgui/wizards.py:559 msgid "Score for incorrect answers" msgstr "Nota para respostas incorrectas" -#: eyegrade/qtgui/wizards.py:548 +#: eyegrade/qtgui/wizards.py:560 msgid "Score for blank answers" msgstr "Nota para respostas en branco" -#: eyegrade/qtgui/wizards.py:551 +#: eyegrade/qtgui/wizards.py:563 msgid "Reset question weights" msgstr "Restablecer os pesos das preguntas" -#: eyegrade/qtgui/wizards.py:554 +#: eyegrade/qtgui/wizards.py:566 msgid "Per-question score weights:" msgstr "Pesos por pregunta:" -#: eyegrade/qtgui/wizards.py:704 +#: eyegrade/qtgui/wizards.py:716 msgid "Automatic scores cannot be computed for this exam." msgstr "Non se poden calcular notas automáticamente para este exame." -#: eyegrade/qtgui/wizards.py:739 +#: eyegrade/qtgui/wizards.py:751 msgid "The score for incorrect and blank answers cannot be greater than 0." msgstr "A nota das respostas incorrectas e en branco non pode ser maior que 0." -#: eyegrade/qtgui/wizards.py:743 +#: eyegrade/qtgui/wizards.py:755 msgid "You must enter the score for correct answers." msgstr "Debe introducir a puntuación das respostas correctas." -#: eyegrade/qtgui/wizards.py:755 +#: eyegrade/qtgui/wizards.py:767 msgid "" "The weights must be the same in all the models, although they may be in a " "different order. You must fix this before computing default scores." @@ -883,23 +891,23 @@ msgstr "" "distinta orde. Debe arreglar este problema antes de calcular as notas por " "defecto." -#: eyegrade/qtgui/wizards.py:763 +#: eyegrade/qtgui/wizards.py:775 msgid "" "The changes you have done to the weights table will be lost. Are you sure " "you want to continue?" msgstr "" -"Perderanse os cambios realizados na táboa de pesos. ¿Seguro que quere " +"Perderanse os cambios realizados na táboa de pesos. Seguro que quere " "continuar?" -#: eyegrade/qtgui/wizards.py:791 +#: eyegrade/qtgui/wizards.py:803 msgid "Create a new session" msgstr "Crear nova sesión" -#: eyegrade/qtgui/wizards.py:846 +#: eyegrade/qtgui/wizards.py:858 msgid "Student id files" msgstr "Listas de estudantes" -#: eyegrade/qtgui/wizards.py:847 +#: eyegrade/qtgui/wizards.py:859 msgid "" "You can select zero, one or more files with the list of student ids. Go to " "the user manual if you don't know the format of the files." @@ -907,11 +915,11 @@ msgstr "" "Pode seleccionar ningún, un ou máis ficheiros de estudantes. O manual de " "usuario explica o formato que deben ter." -#: eyegrade/qtgui/wizards.py:851 +#: eyegrade/qtgui/wizards.py:863 msgid "Select student list files" msgstr "Seleccionar ficheiro de estudantes" -#: eyegrade/qtgui/wizards.py:869 +#: eyegrade/qtgui/wizards.py:881 msgid "Error in student list" msgstr "Erro na lista de estudantes" diff --git a/eyegrade/data/logo.svg b/eyegrade/data/logo.svg index b413081e..e6fc277e 100644 --- a/eyegrade/data/logo.svg +++ b/eyegrade/data/logo.svg @@ -13,7 +13,7 @@ height="128" id="svg2" version="1.1" - inkscape:version="0.48.1 r9760" + inkscape:version="0.48.5 r10040" sodipodi:docname="logo.svg" inkscape:export-filename="icon.png" inkscape:export-xdpi="22.900122" @@ -28,7 +28,7 @@ inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="2.8" - inkscape:cx="9.9956739" + inkscape:cx="29.596423" inkscape:cy="41.413159" inkscape:document-units="px" inkscape:current-layer="layer2" @@ -50,7 +50,7 @@ image/svg+xml - + @@ -75,7 +75,7 @@ style="display:inline" transform="translate(-173.35878,-74.719837)"> diff --git a/eyegrade/data/svm/cross_classifier.dat.gz b/eyegrade/data/svm/cross_classifier.dat.gz new file mode 100644 index 00000000..51ccd81c Binary files /dev/null and b/eyegrade/data/svm/cross_classifier.dat.gz differ diff --git a/eyegrade/data/svm/cross_classifier_metadata.json b/eyegrade/data/svm/cross_classifier_metadata.json new file mode 100644 index 00000000..f565bf07 --- /dev/null +++ b/eyegrade/data/svm/cross_classifier_metadata.json @@ -0,0 +1,18 @@ +{ + "confusion_matrix": [ + [ + 0.9988374627624791, + 0.0011625372375208894 + ], + [ + 0.00024301336573511544, + 0.9997569866342649 + ] + ], + "performance": { + "balanced_success_rate": 0.99929722469837201, + "evaluation_rounds": 100, + "num_samples": 17878, + "success_rate": 0.99904911063877389 + } +} \ No newline at end of file diff --git a/eyegrade/data/svm/digit_classifier.dat.gz b/eyegrade/data/svm/digit_classifier.dat.gz new file mode 100644 index 00000000..1c0340ca Binary files /dev/null and b/eyegrade/data/svm/digit_classifier.dat.gz differ diff --git a/eyegrade/data/svm/digit_classifier_metadata.txt b/eyegrade/data/svm/digit_classifier_metadata.txt new file mode 100644 index 00000000..153335a8 --- /dev/null +++ b/eyegrade/data/svm/digit_classifier_metadata.txt @@ -0,0 +1,130 @@ +{ + "confusion_matrix": [ + [ + 0.9819370912488321, + 0.006228589224540642, + 0.002802865151043289, + 0.0018685767673621925, + 0.0012457178449081284, + 0.0012457178449081284, + 0.002802865151043289, + 0.0003114294612270321, + 0.0009342883836810962, + 0.0006228589224540642 + ], + [ + 0.0028116213683223993, + 0.9709465791940018, + 0.007497656982193065, + 0.005154639175257732, + 0.0018744142455482662, + 0.0009372071227741331, + 0.0009372071227741331, + 0.007029053420805998, + 0.0014058106841611997, + 0.0014058106841611997 + ], + [ + 0.008239700374531835, + 0.024719101123595506, + 0.9146067415730337, + 0.020224719101123594, + 0.00149812734082397, + 0.00149812734082397, + 0.0022471910112359553, + 0.015730337078651686, + 0.008239700374531835, + 0.00299625468164794 + ], + [ + 0.007447528774542992, + 0.01963439404197698, + 0.027758970886932972, + 0.8923493568043331, + 0.0013540961408259986, + 0.013540961408259987, + 0.002031144211238998, + 0.010832769126607989, + 0.01828029790115098, + 0.006770480704129994 + ], + [ + 0.0079155672823219, + 0.032981530343007916, + 0.00395778364116095, + 0.0, + 0.9182058047493403, + 0.005277044854881266, + 0.005277044854881266, + 0.005277044854881266, + 0.00395778364116095, + 0.017150395778364115 + ], + [ + 0.013095238095238096, + 0.015476190476190477, + 0.007142857142857143, + 0.04047619047619048, + 0.0011904761904761906, + 0.8821428571428571, + 0.01904761904761905, + 0.005952380952380952, + 0.010714285714285714, + 0.004761904761904762 + ], + [ + 0.014903129657228018, + 0.01639344262295082, + 0.004470938897168405, + 0.0029806259314456036, + 0.0029806259314456036, + 0.013412816691505217, + 0.9374068554396423, + 0.0029806259314456036, + 0.004470938897168405, + 0.0 + ], + [ + 0.0025940337224383916, + 0.0311284046692607, + 0.04539559014267185, + 0.01556420233463035, + 0.010376134889753566, + 0.0025940337224383916, + 0.0012970168612191958, + 0.8780804150453956, + 0.0, + 0.01297016861219196 + ], + [ + 0.012121212121212121, + 0.03636363636363636, + 0.02727272727272727, + 0.03939393939393939, + 0.006060606060606061, + 0.02727272727272727, + 0.012121212121212121, + 0.004545454545454545, + 0.8227272727272728, + 0.012121212121212121 + ], + [ + 0.014691478942213516, + 0.01665034280117532, + 0.005876591576885406, + 0.004897159647404506, + 0.025465230166503428, + 0.0009794319294809011, + 0.0, + 0.00881488736532811, + 0.004897159647404506, + 0.9177277179236043 + ] + ], + "performance": { + "balanced_success_rate": 0.9116130691848312, + "evaluation_rounds": 100, + "num_samples": 12878, + "success_rate": 0.93081223792514367 + } +} \ No newline at end of file diff --git a/eyegrade/imageproc.py b/eyegrade/detection.py similarity index 75% rename from eyegrade/imageproc.py rename to eyegrade/detection.py index c5242427..7c454ca2 100644 --- a/eyegrade/imageproc.py +++ b/eyegrade/detection.py @@ -22,17 +22,16 @@ import copy import sys +import cv2 +import numpy as np + # Local imports from . import geometry as g -from . import ocr from . import capture from . import sessiondb - -# Import the cv module. It might be cv2.cv in newer versions. -try: - import cv -except ImportError: - import cv2.cv as cv +from . import images +from .ocr import classifiers +from .ocr import sample # Adaptive threshold algorithm param_adaptive_threshold_block_size = 45 @@ -45,28 +44,9 @@ param_failures_threshold = 10 param_check_corners_tolerance_mul = 6 -# Thickness of the cross mask, as a fraction of the width of the cell -param_cross_mask_thickness = 0.2 - -# Number of pixels to go inside de cell for the mask cross -param_cross_mask_margin = 0.6 -param_cross_mask_margin_2 = 0.75 -param_cell_mask_margin = 0.9 - -# Percentage of points of the mask cross that must be active to decide a cross -param_cross_mask_threshold = 0.08 - -# Percentage of points outside the mask cross that must be active to -# decide a cleared answer -param_clear_out_threshold = 0.35 - -# Percentage of points inside the mask cross that must be active to -# decide a cleared answer -param_clear_in_threshold = 0.2 - +# Parameters for the infobits masks param_bit_mask_threshold = 0.25 param_bit_mask_radius_multiplier = 0.333 -param_cell_mask_threshold = 0.6 # Parameters for id boxes detection param_id_boxes_min_energy_threshold = 0.5 @@ -79,8 +59,6 @@ param_error_log = 'eyegrade-errors.log' param_error_image_pattern = 'error-%s.png' -font = cv.InitFont(cv.CV_FONT_HERSHEY_SIMPLEX, 1.0, 1.0, 0, 3) - class ExamDetector(object): @@ -111,14 +89,16 @@ def __init__(self, dimensions, context, options, image_raw=None): self.image_raw = image_raw self.image_proc = pre_process(self.image_raw) elif not self.options['capture-from-file']: - self.image_raw = self.context.capture(clone=True) + self.image_raw = self.context.capture() self.image_proc = pre_process(self.image_raw) elif self.options['capture-raw-file'] is not None: - self.image_raw = load_image(self.options['capture-raw-file']) + self.image_raw = \ + images.load_image(self.options['capture-raw-file']) self.image_proc = pre_process(self.image_raw) elif self.options['capture-proc-file'] is not None: - self.image_raw = load_image(self.options['capture-proc-file']) - self.image_proc = rgb_to_gray(self.image_raw) + self.image_raw = \ + images.load_image(self.options['capture-proc-file']) + self.image_proc = images.rgb_to_gray(self.image_raw) elif self.options['capture-proc-ipl'] is not None: self.image_raw = self.options['capture-proc-ipl'] self.image_proc = self.options['capture-proc-ipl'] @@ -132,9 +112,9 @@ def __init__(self, dimensions, context, options, image_raw=None): 'id-box-hlines': False, 'id-box': False} if self.options['show-image-proc']: - self.image_to_show = gray_ipl_to_rgb(self.image_proc) + self.image_to_show = images.gray_to_rgb(self.image_proc) elif self.options['show-lines']: - self.image_to_show = cv.CloneImage(self.image_raw) + self.image_to_show = self.image_raw.copy() else: self.image_to_show = self.image_raw self.decisions = None @@ -172,17 +152,18 @@ def detect(self): self.context.next_hough_threshold() else: self.status['boxes'] = True - axes = filter_axes(axes, self.dimensions, self.image_raw.width, - self.image_raw.height, self.options['read-id']) + axes = filter_axes(axes, self.dimensions, + images.width(self.image_raw), + images.height(self.image_raw), + self.options['read-id']) corner_matrixes = cell_corners(axes[1][1], axes[0][1], - self.image_raw.width, - self.image_raw.height, + images.width(self.image_raw), + images.height(self.image_raw), self.dimensions) if len(corner_matrixes) > 0: self.status['cells'] = True - if len(corner_matrixes) > 0: answer_cells = self._answer_cells_geometry(corner_matrixes) - answers = decide_cells(self.image_proc, answer_cells) + answers = self._decide_cells(answer_cells) if self.options['infobits']: bits = read_infobits(self.image_proc, corner_matrixes) if bits is not None: @@ -214,17 +195,17 @@ def detect(self): if self.options['show-lines']: if self.status['cells']: for line in axes[0][1]: - draw_line(self.image_to_show, line, (255, 0, 0)) + images.draw_line(self.image_to_show, line, (255, 0, 0)) for line in axes[1][1]: - draw_line(self.image_to_show, line, (255, 0, 255)) + images.draw_line(self.image_to_show, line, (255, 0, 255)) self._draw_cell_corners(corner_matrixes) if id_hlines: for line in id_hlines: - draw_line(self.image_to_show, line, (255, 255, 0)) + images.draw_line(self.image_to_show, line, (255, 255, 0)) if id_cells: for cell in id_cells: for corner in cell.corners(): - draw_point(self.image_to_show, corner) + images.draw_point(self.image_to_show, corner) if self.options['show-status']: self._draw_status_flags() self.decisions = capture.ExamDecisions(success, answers, detected_id, @@ -244,7 +225,7 @@ def detect_manual(self, manual_points): if corner_matrixes != []: self.status['cells'] = True answer_cells = self._answer_cells_geometry(corner_matrixes) - answers = decide_cells(self.image_proc, answer_cells) + answers = self._decide_cells(answer_cells) if self.options['infobits']: bits = read_infobits(self.image_proc, corner_matrixes) if bits is not None: @@ -297,8 +278,9 @@ def _write_error_trace(self, exc_type, exc_value, exc_traceback): file=file_) file_.close() im_file = param_error_image_pattern%re.sub(r'[-\ \.:]', '_', date) - cv.SaveImage(os.path.join(self.options['logging-dir'], im_file), - self.image_raw) + capture.save_image(os.path.join(self.options['logging-dir'], + im_file), + self.image_raw) else: traceback.print_exception(exc_type, exc_value, exc_traceback) @@ -319,6 +301,18 @@ def _answer_cells_geometry(self, corner_matrixes): cells = self._set_left_to_right(cells) return cells + def _decide_cells(self, answer_cells): + decisions = [] + for row in answer_cells: + row_decisions = [] + for cell in row: + corners = np.array([cell.plu, cell.pru, cell.pld, cell.prd]) + samp = sample.CrossSampleFromCam(corners, self.image_proc) + decision = self.context.crosses_classifier.is_cross(samp) + row_decisions.append(decision) + decisions.append(decide_answer(row_decisions)) + return decisions + def _set_left_to_right(self, cells): """Sets left to right order in cell geometry.""" cells_transposed = [] @@ -361,10 +355,9 @@ def _detect_id(self, id_cells): digits = [] id_scores = [] for cell in id_cells: - corners = (cell.plu, cell.pru, cell.pld, cell.prd) - digit, scores = (ocr.digit_ocr(self.image_proc, corners, - self.options['debug-ocr'], - self.image_to_show)) + corners = np.array([cell.plu, cell.pru, cell.pld, cell.prd]) + samp = sample.DigitSampleFromCam(corners, self.image_proc) + digit, scores = self.context.ocr.classify_digit(samp) digits.append(digit) id_scores.append(scores) detected_id = "".join([str(d) if d is not None else '0' \ @@ -383,22 +376,23 @@ def _draw_status_flags(self): color_bad = (0, 0, 255) y = 75 width = 24 - x = self.image_to_show.width - 5 - len(flags) * width + x = images.width(self.image_to_show) - 5 - len(flags) * width for letter, value in flags: color = color_good if value else color_bad - draw_text(self.image_to_show, letter, color, (x, y)) + images.draw_text(self.image_to_show, letter, color, (x, y)) x += width def _draw_hough_threshold(self): - pos = (self.image_to_show.width - 77, 110) - draw_text(self.image_to_show, str(self.context.get_hough_threshold()), - position=pos) + pos = (images.width(self.image_to_show) - 77, 110) + images.draw_text(self.image_to_show, + str(self.context.get_hough_threshold()), + position=pos) def _draw_cell_corners(self, corner_matrixes): for corners in corner_matrixes: for h in corners: for c in h: - draw_point(self.image_to_show, c) + images.draw_point(self.image_to_show, c) class ExamDetectorContext(object): @@ -423,6 +417,8 @@ def __init__(self, camera_id=-1, fixed_hough_threshold=None): self.camera = None self.camera_id = camera_id self.threshold_locked = False + self.ocr = classifiers.DefaultDigitClassifier() + self.crosses_classifier = classifiers.DefaultCrossesClassifier() def open_camera(self, camera_id=None): """Initializes the last camera device used, or `camera_id`. @@ -504,7 +500,8 @@ def close_camera(self): The same camera will be opened again when open_camera() is called. """ - del self.camera + if self.camera is not None: + self.camera.release() self.camera = None def capture(self, clone=False, resize=None): @@ -525,16 +522,27 @@ def capture(self, clone=False, resize=None): # The image will be cloned when resizing clone = False if self.camera is not None: - image = capture_image(self.camera, clone=clone) + image = self.capture_image(clone=clone) + if image is None: + image = np.zeros((480, 640, 3), dtype=np.uint8) if resize is not None: - image = resize_image(image, resize) + image = cv2.resize(image, resize, interpolation=cv2.INTER_AREA) return image def dump_buffer(self, delay_suffered): if self.camera is not None and delay_suffered > 0.1: frames_to_drop = min(8, int(1 + (delay_suffered - 0.1) / 0.04)) for i in range(0, frames_to_drop): - capture_image(self.camera, False) + self.capture_image(False) + + def capture_image(self, clone=False): + success, image = self.camera.read() + if not success: + image = None + elif clone: + return image.copy() + else: + return image def _try_next_camera(self, cur_camera_id): camera = None @@ -546,13 +554,14 @@ def _try_next_camera(self, cur_camera_id): break return (camera, camera_id) - def _try_camera(self, camera_id): - cam = cv.CaptureFromCAM(camera_id) - image = cv.QueryFrame(cam) - if image is not None: - return cam - else: - return None + @staticmethod + def _try_camera(camera_id): + cam = cv2.VideoCapture(camera_id) + if cam is not None: + success, image = cam.read() + if success is None or image is None: + cam = None + return cam class FalseExamDetectorContext(ExamDetectorContext): @@ -595,88 +604,22 @@ def notify_success(self): self.next_exam_idx = 0 -def init_camera(input_dev = -1): - return cv.CaptureFromCAM(input_dev) - -def capture_image(camera, clone = False): - image = cv.QueryFrame(camera) - if clone: - return cv.CloneImage(image) - else: - return image - -def resize_image(image, size): - new_image = cv.CreateImage(size, image.depth, image.nChannels) - cv.Resize(image, new_image, interpolation=cv.CV_INTER_AREA) - return new_image - def pre_process(image): - gray = rgb_to_gray(image) - thr = cv.CreateImage((image.width, image.height), image.depth, 1) - cv.AdaptiveThreshold(gray, thr, 255, - cv.CV_ADAPTIVE_THRESH_GAUSSIAN_C, - cv.CV_THRESH_BINARY_INV, - param_adaptive_threshold_block_size, - param_adaptive_threshold_offset) + gray = images.rgb_to_gray(image) + thr = cv2.adaptiveThreshold(gray, 255, + cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY_INV, + param_adaptive_threshold_block_size, + param_adaptive_threshold_offset) return thr -def gray_ipl_to_rgb(image): - rgb = cv.CreateImage((image.width, image.height), image.depth, 3) - cv.CvtColor(image, rgb, cv.CV_GRAY2RGB) - return rgb - -def rgb_to_gray(image): - gray = cv.CreateImage((image.width, image.height), image.depth, 1) - cv.CvtColor(image, gray, cv.CV_RGB2GRAY) - return gray - -def load_image_grayscale(filename): - return rgb_to_gray(cv.LoadImage(filename)) - -def load_image(filename): - return cv.LoadImage(filename) - -def draw_line(image, line, color = (0, 0, 255, 0)): - theta = line[1] - points = set() - if math.sin(theta) != 0.0: - points.add(g.line_point(line, x=0)) - points.add(g.line_point(line, x=image.width - 1)) - if math.cos(theta) != 0.0: - points.add(g.line_point(line, y=0)) - points.add(g.line_point(line, y=image.height - 1)) - p_draw = [p for p in points if p[0] >= 0 and p[1] >= 0 - and p[0] < image.width and p[1] < image.height] - if len(p_draw) == 2: - cv.Line(image, p_draw[0], p_draw[1], color, 1) - -def draw_point(image, point, color = (255, 0, 0, 0), radius = 2): - x, y = point - if x >= 0 and x < image.width and y >= 0 and y < image.height: - cv.Circle(image, point, radius, color, cv.CV_FILLED) - else: - print "draw_point: bad point (%d, %d)"%(x, y) - -def draw_cross_mask(image, plu, pru, pld, prd, color, thickness): - cv.Line(image, plu, prd, color, int(thickness)) - cv.Line(image, pld, pru, color, int(thickness)) - -def draw_text(image, text, color = (255, 0, 0), position = (10, 30)): - cv.PutText(image, text, position, font, color) - def detect_lines(image, hough_threshold): - st = cv.CreateMemStorage() - lines = cv.HoughLines2(image, st, cv.CV_HOUGH_STANDARD, - 1, 0.01, hough_threshold) - - # Trick to use both new and old style bindings - len_lines = len(lines) - if len_lines > 500: + lines = cv2.HoughLines(image, 1, 0.01, hough_threshold) + if lines is None or len(lines[0]) > 500: return [] - - s_lines = sorted([(float(l[0]), float(l[1])) for l in lines], - key = lambda x: x[1]) - return s_lines + lines = lines[0] + return sorted([(float(l[0]), float(l[1])) for l in lines], + key = lambda x: x[1]) def detect_directions(lines): assert(len(lines) >= 2) @@ -864,87 +807,39 @@ def check_corners(corner_matrixes, width, height): # Success if control reaches here return True -def decide_cells(image, answer_cells): - dim = (image.width, image.height) - mask = cv.CreateImage(dim, 8, 1) - masked = cv.CreateImage(dim, 8, 1) - decisions = [] - for row in answer_cells: - row_decisions = [] - for cell in row: - decision = decide_cell(image, mask, masked, - cell.plu, cell.pru, cell.pld, cell.prd) - row_decisions.append(decision) - decisions.append(decide_answer(row_decisions)) - return decisions - -def decide_cell(image, mask, masked, plu, pru, pld, prd): - thickness = g.distance(plu, pru) * param_cross_mask_thickness - cv.SetZero(mask) - iplu, iprd = g.closer_points_rel(plu, prd, param_cross_mask_margin, - thickness / 2) - ipru, ipld = g.closer_points_rel(pru, pld, param_cross_mask_margin, - thickness / 2) - draw_cross_mask(mask, iplu, ipru, ipld, iprd, (1), thickness) - iplu, iprd = g.closer_points_rel(plu, prd, param_cross_mask_margin_2, - thickness / 4) - ipru, ipld = g.closer_points_rel(pru, pld, param_cross_mask_margin_2, - thickness / 4) - draw_cross_mask(mask, iplu, ipru, ipld, iprd, (1), thickness / 2) - mask_pixels = cv.CountNonZero(mask) - cv.Mul(image, mask, masked) - masked_pixels = cv.CountNonZero(masked) - cell_marked = masked_pixels > param_cross_mask_threshold * mask_pixels - # If the whole cell is marked, don't count the result: - if cell_marked: - iplu, iprd = g.closer_points_rel(plu, prd, param_cell_mask_margin) - ipru, ipld = g.closer_points_rel(pru, pld, param_cell_mask_margin) - pix_total, pix_set = count_pixels_in_cell(image, iplu, ipru, ipld, iprd) - cell_marked = (masked_pixels < param_clear_in_threshold * mask_pixels or - (pix_set - masked_pixels) < (pix_total - mask_pixels) * \ - param_clear_out_threshold) - # Debug -# iplu, iprd = closer_points_rel(plu, prd, param_cell_mask_margin) -# ipru, ipld = closer_points_rel(pru, pld, param_cell_mask_margin) -# pix_total, pix_set = count_pixels_in_cell(image, iplu, ipru, ipld, iprd) -# print 'total: %d, set: %d, mask: %d, masked: %d'%(pix_total, pix_set, -# mask_pixels, -# masked_pixels) - return cell_marked - def read_infobits(image, corner_matrixes): - dim = (image.width, image.height) - mask = cv.CreateImage(dim, 8, 1) - masked = cv.CreateImage(dim, 8, 1) + mask = images.new_image(images.width(image), images.height(image), 1) bits = [] for corners in corner_matrixes: for i in range(1, len(corners[0])): dx = g.diff_points(corners[-1][i - 1], corners[-1][i]) dy = g.diff_points(corners[-1][i], corners[-2][i]) - center = g.round_point((corners[-1][i][0] + dx[0] / 2 + dy[0] / 2.6, - corners[-1][i][1] + dx[1] / 2 + dy[1] / 2.6)) - bits.append(decide_infobit(image, mask, masked, center, dy)) + center = g.round_point((corners[-1][i][0] + + dx[0] / 2 + dy[0] / 2.6, + corners[-1][i][1] + + dx[1] / 2 + dy[1] / 2.6)) + bits.append(decide_infobit(image, mask, center, dy)) # Check validity if min([b[0] ^ b[1] for b in bits]) == True: return [b[0] for b in bits] else: return None -def decide_infobit(image, mask, masked, center_up, dy): +def decide_infobit(image, mask, center_up, dy): center_down = g.add_points(center_up, dy) radius = int(round(math.sqrt(dy[0] * dy[0] + dy[1] * dy[1]) \ * param_bit_mask_radius_multiplier)) if radius == 0: radius = 1 - cv.SetZero(mask) - cv.Circle(mask, center_up, radius, (1), cv.CV_FILLED) - mask_pixels = cv.CountNonZero(mask) - cv.Mul(image, mask, masked) - masked_pixels_up = cv.CountNonZero(masked) - cv.SetZero(mask) - cv.Circle(mask, center_down, radius, (1), cv.CV_FILLED) - cv.Mul(image, mask, masked) - masked_pixels_down = cv.CountNonZero(masked) + images.zero_image(mask) + cv2.circle(mask, center_up, radius, (1), thickness=-1) + mask_pixels = cv2.countNonZero(mask) + masked = cv2.multiply(image, mask) + masked_pixels_up = cv2.countNonZero(masked) + images.zero_image(mask) + cv2.circle(mask, center_down, radius, (1), thickness=-1) + masked = cv2.multiply(image, mask) + masked_pixels_down = cv2.countNonZero(masked) if mask_pixels < 1: return (False, False) return (float(masked_pixels_up) / mask_pixels >= param_bit_mask_threshold, @@ -973,12 +868,12 @@ def id_boxes_geometry(image, num_cells, lines, dimensions): return None, None # Now, adjust corners pairs_left, pairs_right = line_bounds_adaptive(image, hlines[0], hlines[1], - image.width, 5) + images.width(image), 5) all_bounds = [(l[0], r[0], l[1], r[1]) \ for l in pairs_left for r in pairs_right] for bounds in all_bounds[:5]: corners = id_boxes_check_points(image, bounds, hlines, - image.width, num_cells) + images.width(image), num_cells) if corners is not None: success = True break @@ -1120,72 +1015,8 @@ def id_boxes_match_level(image, p0, p1): active = len([(x, y) for (x, y) in points if image[y, x] > 0]) return float(active) / len(points) -def save_image(filename, image): - """Saves a IPL image. Wrapper for cv.SaveImage.""" - cv.SaveImage(filename, image) - # Utility functions # -def count_pixels_in_cell(image, plu, pru, pld, prd): - """ - Count the number of pixels in a given quadrilateral. - Returns a tuple with the total number of pixels and the - number of non-zero pixels. - """ - # Walk the quadrilateral in horizontal lines. - total = 0 - marked = 0 - points = sorted([plu, pru, pld, prd], key = lambda p: p[1]) - same_side = (points[0][0] < points[1][0] and points[2][0] < points[3][0] or - points[0][0] > points[1][0] and points[2][0] > points[3][0]) - if points[0][1] < points[1][1]: - slope1 = g.slope_inv(points[0], points[1]) - if same_side: - slope2 = g.slope_inv(points[0], points[2]) - else: - slope2 = g.slope_inv(points[0], points[3]) - t, m = count_pixels_horiz(image, points[0], slope1, points[0], slope2, - points[0][1], points[1][1]) - total += t - marked += m - if points[1][1] < points[2][1]: - if same_side: - slope1 = g.slope_inv(points[0], points[2]) - slope2 = g.slope_inv(points[1], points[3]) - else: - slope1 = g.slope_inv(points[0], points[3]) - slope2 = g.slope_inv(points[1], points[2]) - t, m = count_pixels_horiz(image, points[0], slope1, points[1], slope2, - points[1][1], points[2][1]) - total += t - marked += m - if points[2][1] < points[3][1]: - slope1 = g.slope_inv(points[2], points[3]) - if same_side: - slope2 = g.slope_inv(points[1], points[3]) - point2 = points[1] - else: - slope2 = g.slope_inv(points[2], points[3]) - point2 = points[2] - t, m = count_pixels_horiz(image, points[2], slope1, point2, slope2, - points[2][1], points[3][1]) - total += t - marked += m - return total, marked - -def count_pixels_horiz(image, p0, slope0, p1, slope1, yini, yend): - pix_total = 0 - pix_marked = 0 - for y in range(yini, yend): - x1 = int(round(p0[0] + slope0 * (y - p0[1]))) - x2 = int(round(p1[0] + slope1 * (y - p1[1]))) - inc = 1 if x1 < x2 else -1 - for x in range(x1, x2 + inc, inc): - pix_total += 1 - if image[y, x] > 0: - pix_marked += 1 - return (pix_total, pix_marked) - def line_bounds_adaptive(image, line_up, line_down, iwidth, rho_var): points_left_up, points_right_up = \ line_bounds_one_line(image, line_up, iwidth, rho_var) @@ -1230,11 +1061,10 @@ def line_bounds(image, line, iwidth): p1 = g.line_point(line, x=iwidth - 1) if p1[1] < 0: p1 = g.line_point(line, y=0) - - if (not g.point_is_valid(p0, (image.width, image.height)) - or not g.point_is_valid(p1, (image.width, image.height))): + image_dimensions = (images.width(image), images.height(image)) + if (not g.point_is_valid(p0, image_dimensions) + or not g.point_is_valid(p1, image_dimensions)): return None, None - # get bounds ini_found = False ini = None @@ -1262,29 +1092,6 @@ def line_bounds(image, line, iwidth): end = None return ini, end -def count_pixels_in_horizontal_line(image, line): - p0 = g.line_point(line, x=0) - if p0[1] < 0: - p0 = g.line_point(line, y=0) - p1 = g.line_point(line, x=image.width - 1) - if p1[1] < 0: - p1 = g.line_point(line, y=0) - active_points = 0 - for x, y in g.walk_line(p0, p1): - if image[y, x] > 0: - active_points += 1 - return active_points - -## def cvimage_to_pygame(image): -## if cv_new_style: -## image_rgb = cv.CreateMat(image.height, image.width, cv.CV_8UC3) -## cv.CvtColor(image, image_rgb, cv.CV_BGR2RGB) -## return pygame.image.frombuffer(image_rgb.tostring(), -## cv.GetSize(image_rgb), 'RGB') -## else: -## im = cv.ipl_to_pil(image) -## return pygame.image.frombuffer(im.tostring(), im.size, im.mode) - def process_box_corners(points, dimensions): num_boxes = len(dimensions) points.sort() @@ -1365,6 +1172,7 @@ def fix_box_if_needed(box_corners): print ' -> points at the rigth fixed' return (plu, pru, pld, prd) + ## Debug the upper case with these points: (70, 102), (297, 270), ## (62, 276), (260, 101), ## (390, 269), (589, 264), (388, 98), (574, 103) diff --git a/eyegrade/exammaker.py b/eyegrade/exammaker.py index 2bcaaca7..82f5170b 100644 --- a/eyegrade/exammaker.py +++ b/eyegrade/exammaker.py @@ -19,11 +19,11 @@ import re import copy import sys +import subprocess +import os from . import utils -EyegradeException = utils.EyegradeException - param_min_num_questions = 1 # For formatting questions @@ -35,30 +35,33 @@ re_split_template = re.compile('{{([^{}]+)}}') # Register user-friendly error messages -EyegradeException.register_error('incoherent_exam_config', +utils.EyegradeException.register_error('incoherent_exam_config', 'The exam you are attempting to create is not compatible\n' 'with the already existing .eye exam configuration file.\n' 'This happens, for example, when the configuration file\n' 'contains more or less questions than the exam you are now\n' 'creating. If you really want to discard the previous .eye file,\n' 'use the --force option, or just remove the file manually.') -EyegradeException.register_error('incoherent_num_tables', +utils.EyegradeException.register_error('incoherent_num_tables', 'The specified number of tables and the exam dimensions are not\n' 'compatible. The exam dimensions implicitly specify the number of tables.\n' 'Therefore, there is no need to explicitly specify the number of tables.', 'Incoherent number of tables.') -EyegradeException.register_error('bad_model_value', +utils.EyegradeException.register_error('bad_model_value', 'A model must be represented by an uppercase English letter (A-Z).\n' 'You can also create an unshuffled version of the exam with the\n' "special '0' model.", 'Bad model value.') -EyegradeException.register_error('too_few_questions', +utils.EyegradeException.register_error('too_few_questions', short_message='At least %d question(s) needed'%param_min_num_questions) -EyegradeException.register_error('too_few_choices', +utils.EyegradeException.register_error('too_few_choices', short_message='At least 2 choices per question needed') -EyegradeException.register_error('too_many_tables', +utils.EyegradeException.register_error('too_many_tables', 'There cannot be less than two questions per table.', 'There are too many tables for such a few questions') +utils.EyegradeException.register_error('latex_not_found', + 'Install LaTeX and make sure it is in your system\'s PATH variable.', + 'The command pdflatex was not found.') class ExamMaker(object): @@ -85,7 +88,7 @@ def __init__(self, num_questions, num_choices, template_filename, self.exam_config_filename = exam_config_filename if (num_tables > 0 and dimensions is not None and len(dimensions) != num_tables): - raise EyegradeException('', key='incoherent_num_tables') + raise utils.EyegradeException('', key='incoherent_num_tables') if dimensions is not None: self.dimensions = dimensions else: @@ -99,10 +102,10 @@ def __init__(self, num_questions, num_choices, template_filename, else: self.table_width = table_width self.table_height = table_height - self.__load_replacements(variables, id_label) + self._load_replacements(variables, id_label) if self.exam_config_filename is not None: if not force_config_overwrite: - self.__load_exam_config() + self._load_exam_config() else: self._new_exam_config() else: @@ -117,7 +120,8 @@ def set_exam_questions(self, exam): raise Exception('Incorrect number of questions') self.exam_questions = exam - def create_exam(self, model, shuffle, with_solution=False): + def create_exam(self, model, shuffle, with_solution=False, + produce_pdf=False): """Creates a new exam. 'shuffle' must be a boolean. If True, the exam is shuffled @@ -127,7 +131,7 @@ def create_exam(self, model, shuffle, with_solution=False): """ if model is None or len(model) != 1 or ((ord(model) < 65 or \ ord(model) > 90) and model != '0'): - raise EyegradeException('', 'bad_model_value') + raise utils.EyegradeException('', 'bad_model_value') replacements = copy.copy(self.replacements) answer_table = create_answer_table(self.dimensions, model, self.table_width, self.table_height, @@ -161,38 +165,50 @@ def create_exam(self, model, shuffle, with_solution=False): # Replacement keys are in odd positions of self.parts replaced = len(self.parts) * [None] replaced[::2] = self.parts[::2] - replaced[1::2] = [self.__replace(key, replacements) \ + replaced[1::2] = [self._replace(key, replacements) \ for key in self.parts[1::2]] exam_text = ''.join(replaced) if self.output_file == sys.stdout: utils.write_to_stdout(exam_text) + produced_filename = None else: - utils.write_file(self.output_file%model, exam_text) + produced_filename = self.output_file%model + utils.write_file(produced_filename, exam_text) + if produce_pdf: + success, output, produced_filename = \ + compile_latex(produced_filename, remove_tex=True) + if not success: + raise utils.EyegradeException(output) + return produced_filename def save_exam_config(self): if self.exam_config is not None: self.exam_config.save(self.exam_config_filename) - def __load_exam_config(self): + def _load_exam_config(self): if self.exam_config_filename is not None: try: self.exam_config = utils.ExamConfig(self.exam_config_filename) if self.num_questions != self.exam_config.num_questions: - raise EyegradeException('Incoherent number of questions', + raise utils.EyegradeException( \ + 'Incoherent number of questions', key='incoherent_exam_config') if self.id_num_digits != self.exam_config.id_num_digits: - raise EyegradeException( + raise utils.EyegradeException( 'Incoherent configuration of id box', key='incoherent_exam_config') if self.dimensions != self.exam_config.dimensions: - raise EyegradeException('Incoherent table dimensions', + raise utils.EyegradeException( \ + 'Incoherent table dimensions', key='incoherent_exam_config') if (self.left_to_right_numbering != self.exam_config.left_to_right_numbering): - raise EyegradeException('Incoherent question numbering', + raise utils.EyegradeException( \ + 'Incoherent question numbering', key='incoherent_exam_config') if self.survey_mode != self.exam_config.survey_mode: - raise EyegradeException('Incoherent survey mode value', + raise utils.EyegradeException( \ + 'Incoherent survey mode value', key='incoherent_exam_config') except IOError: self._new_exam_config() @@ -254,14 +270,14 @@ def _compute_table_size(self): id_width = None return width, height, id_width - def __load_replacements(self, variables, id_label): + def _load_replacements(self, variables, id_label): self.replacements = copy.copy(variables) self.replacements['id-box'] = create_id_box(id_label, self.id_num_digits, self.id_box_width) self.replacements['questions'] = '' - def __replace(self, key, replacements): + def _replace(self, key, replacements): if key in replacements: if not replacements[key] and not key in self.empty_variables: self.empty_variables.append(key) @@ -273,6 +289,47 @@ def __replace(self, key, replacements): else: raise Exception('Unknown replacement key: ' + key) +def check_latex(): + with open(os.devnull, 'w') as devnull: + try: + subprocess.check_call(['pdflatex', '-version'], + stdout=devnull, stderr=devnull) + except: + success = False + else: + success = True + return success + +def compile_latex(latex_file, remove_tex=False): + directory, name = os.path.split(latex_file) + base_name = os.path.splitext(name)[0] + with utils.change_dir(directory): + try: + output = subprocess.check_output(['pdflatex', + '-interaction=nonstopmode', + name], + stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + output = e.output + success = False + produced_filename = None + except OSError: + success = False + raise utils.EyegradeException('', key='latex_not_found') + else: + success = True + produced_filename = os.path.join(directory, base_name + '.pdf') + finally: + to_remove = [base_name + '.aux'] + if success: + to_remove.append(base_name + '.log') + if remove_tex: + to_remove.append(name) + for filename in to_remove: + if os.path.isfile(filename): + os.remove(filename) + return success, output, produced_filename + def latex_declarations(with_solution): """Returns the list of declarations to be set in the preamble of the LaTeX output. @@ -316,18 +373,18 @@ def create_answer_table(dimensions, model, table_width=None, table_height=None, num_tables = len(dimensions) for d in dimensions: if d[0] != num_choices: - raise EyegradeException('', 'same_num_choices') + raise utils.EyegradeException('', 'same_num_choices') if model != '0': bits = utils.encode_model(model, num_tables, num_choices) else: bits = [False] * num_tables * num_choices - bits_rows = __create_infobits(bits, num_tables, num_choices) + bits_rows = _create_infobits(bits, num_tables, num_choices) tables, question_numbers = table_geometry(dimensions) - rows = __table_top(num_tables, num_choices, compact, table_width, + rows = _table_top(num_tables, num_choices, compact, table_width, table_height) for i, row_geometry in enumerate(tables): - rows.append(__horizontal_line(row_geometry, num_choices, compact)) - rows.append(__build_row(i, row_geometry, question_numbers, + rows.append(_horizontal_line(row_geometry, num_choices, compact)) + rows.append(_build_row(i, row_geometry, question_numbers, num_choices, bits_rows, compact, left_to_right_numbering)) rows.append(r'\end{tabular}') @@ -378,13 +435,13 @@ def compute_table_dimensions(num_questions, num_choices, num_tables): """ if num_questions < param_min_num_questions: - raise EyegradeException('', key='too_few_questions') + raise utils.EyegradeException('', key='too_few_questions') if num_choices < 2: - raise EyegradeException('', key='too_few_choices') + raise utils.EyegradeException('', key='too_few_choices') if num_tables <= 0: - num_tables = __choose_num_tables(num_questions) + num_tables = _choose_num_tables(num_questions) elif num_tables * 2 > num_questions: - raise EyegradeException('', key='too_many_tables') + raise utils.EyegradeException('', key='too_many_tables') dimensions = [] rows_per_table, extra_rows = divmod(num_questions, num_tables) for i in range(0, num_tables): @@ -416,7 +473,7 @@ def table_geometry(dimensions): question_numbers.append(question_numbers[-1] + dimensions[i][1]) return tables, question_numbers -def __choose_num_tables(num_questions): +def _choose_num_tables(num_questions): """Returns a good number of tables for the given number of questions.""" num_tables = 1 for numq in param_table_limits: @@ -426,7 +483,7 @@ def __choose_num_tables(num_questions): num_tables += 1 return num_tables -def __horizontal_line(row_geometry, num_choices, compact): +def _horizontal_line(row_geometry, num_choices, compact): parts = [] num_empty_columns = 1 if not compact else 0 first = 2 @@ -438,7 +495,7 @@ def __horizontal_line(row_geometry, num_choices, compact): first += 1 + num_empty_columns + num_choices return ' '.join(parts) -def __table_top(num_tables, num_choices, compact, table_width=None, +def _table_top(num_tables, num_choices, compact, table_width=None, table_height=None): middle_sep_format = 'p{3mm}' if not compact else '' middle_sep_header = ' & & ' if not compact else ' & ' @@ -463,7 +520,7 @@ def __table_top(num_tables, num_choices, compact, table_width=None, lines.append(middle_sep_header.join(parts) + r' \\') return lines -def __build_row(num_row, row_geometry, question_numbers, num_choices, +def _build_row(num_row, row_geometry, question_numbers, num_choices, infobits_row, compact, left_to_right_numbering=False): parts = [] for i, geometry in enumerate(row_geometry): @@ -472,7 +529,7 @@ def __build_row(num_row, row_geometry, question_numbers, num_choices, cell_number = num_row + question_numbers[i] else: cell_number = i + 1 + len(row_geometry) * num_row - parts.append(__build_question_cell(cell_number, geometry)) + parts.append(_build_question_cell(cell_number, geometry)) elif geometry == -1: parts.append(infobits_row[0][i]) elif geometry == -2: @@ -482,13 +539,13 @@ def __build_row(num_row, row_geometry, question_numbers, num_choices, row = ' & & '.join(parts) if not compact else ' & '.join(parts) return row + r' \\' -def __build_question_cell(num_question, num_choices): +def _build_question_cell(num_question, num_choices): parts = [str(num_question)] for i in range(0, num_choices): parts.append(r'\light{%s}'%chr(65 + i)) return ' & '.join(parts) -def __create_infobits(bits, num_tables, num_choices): +def _create_infobits(bits, num_tables, num_choices): column_active = r'\multicolumn{1}{c}{$\blacksquare$}' column_inactive = r'\multicolumn{1}{c}{}' parts = [[], []] @@ -566,7 +623,8 @@ def format_question(question, model, with_solution=False): data.append('&\n') if question.text.figure is not None: data.extend(write_figure(question.text.figure, - question.text.annex_width)) + question.text.annex_width, + True)) elif question.text.code is not None: data.extend(write_code(question.text.code)) data.append('\\\\\n\\end{tabular}\n') @@ -585,17 +643,20 @@ def format_question_component(component): data.extend(write_code(part[1])) if component.figure is not None and component.annex_pos == 'center': data.extend(write_figure(component.figure, - component.annex_width)) + component.annex_width, + False)) elif component.code is not None and component.annex_pos == 'center': data.extend(write_code(component.code)) return data -def write_figure(figure, width): +def write_figure(figure, width, center): data = [] - data.append('\\begin{center}\n') + if center: + data.append('\\begin{center}\n') data.append('\\includegraphics[width=%f\\textwidth]{%s}\n'%\ (width * 0.9, figure)) - data.append('\\end{center}\n') + if center: + data.append('\\end{center}\n') return data def write_code(code): diff --git a/eyegrade/experiments/eval_ocr.py b/eyegrade/experiments/eval_ocr.py deleted file mode 100644 index 4de1b65c..00000000 --- a/eyegrade/experiments/eval_ocr.py +++ /dev/null @@ -1,120 +0,0 @@ -# Eyegrade: grading multiple choice questions with a webcam -# Copyright (C) 2010-2015 Jesus Arias Fisteus -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see -# . -# - -import sys - -from .. import imageproc -from .. import ocr - -class Digit(object): - def __init__(self, image_path, position, correct_digit, - plu, pru, pld, prd): - self.image_path = image_path - self.position = position - self.correct_digit = correct_digit - self.plu = plu - self.pru = pru - self.pld = pld - self.prd = prd - self.detected_digit = None - self.scores = None - - def set_image(self, image): - self.image = image - - def get_cell_corners(self): - return (self.plu, self.pru, self.pld, self.prd) - - def get_success(self): - assert self.detected_digit is not None - if self.correct_digit == self.detected_digit: - return 1 - else: - return 0 - - def get_failure(self): - assert self.detected_digit is not None - if self.correct_digit == self.detected_digit: - return 0 - else: - return 1 - - def __str__(self): - if self.detected_digit is not None: - return '%s@%d: %d (detected %d)'%(self.image_path, self.position, - self.correct_digit, - self.detected_digit) - else: - return '%s@%d: %d (none detected)'%(self.image_path, self.position, - self.correct_digit) - @staticmethod - def parse_digit(line): - parts = [p.strip() for p in line.split('\t')] - assert len(parts) == 11 - image_path = parts[0] - correct_digit = int(parts[1]) - position = int(parts[2]) - plu = Digit.parse_point(parts[3]) - pru = Digit.parse_point(parts[4]) - pld = Digit.parse_point(parts[5]) - prd = Digit.parse_point(parts[6]) - return Digit(image_path, position, correct_digit, plu, pru, pld, prd) - - @staticmethod - def parse_point(point_str): - assert point_str[0] == '(' - assert point_str[-1] == ')' - parts = [p.strip() for p in point_str[1:-1].split(',')] - assert len(parts) == 2 - return (int(parts[0]), int(parts[1])) - - -def eval_digit(digit): - decision, scores = ocr.digit_ocr(digit.image, digit.get_cell_corners()) - digit.scores = scores - if decision is not None: - digit.detected_digit = int(decision) - else: - digit.detected_digit = 0 - -def main(): - evaluated_digits = [] - with open(sys.argv[1], 'r') as file_: - image = None - last_image_path = None - for line in file_: - if line.strip() == '': - continue - digit = Digit.parse_digit(line) - if digit.image_path == last_image_path: - digit.set_image(image) - else: - image = imageproc.load_image_grayscale(digit.image_path) - last_image_path = digit.image_path - digit.set_image(image) - eval_digit(digit) - evaluated_digits.append(digit) - print digit - num_correct = sum([d.get_success() for d in evaluated_digits]) - print 'Decisions: %d; correct: %d; incorrect: %d; success_rate: %.4f'%\ - (len(evaluated_digits), num_correct, - len(evaluated_digits) - num_correct, - float(num_correct) / len(evaluated_digits)) - -if __name__ == '__main__': - main() diff --git a/eyegrade/experiments/extract_crosses.py b/eyegrade/experiments/extract_crosses.py new file mode 100644 index 00000000..fed3bace --- /dev/null +++ b/eyegrade/experiments/extract_crosses.py @@ -0,0 +1,107 @@ +# Eyegrade: grading multiple choice questions with a webcam +# Copyright (C) 2010-2015 Jesus Arias Fisteus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see +# . +# +from __future__ import print_function, division + +import sys +import random +import logging + +import numpy as np +import cv2 + +from .. import sessiondb +from .. import detection +from .. import images +from ..ocr import sample + +VALID_LABELS = (0, 1) + + +class LabeledCross(object): + + def __init__(self, label, image_file, corners, identifier=None): + if not label in VALID_LABELS: + raise ValueError('Wrong label value') + self.label = label + self.image_file = image_file + self.corners = corners + if identifier is not None: + self.identifier = identifier + else: + self.identifier = random.randint(0, 100000000000000) + + def __str__(self): + data = [self.image_file, str(self.label)] + data.extend(str(n) for n in self.corners.reshape(8).tolist()) + return '\t'.join(data) + + def crop(self): + original = images.load_image(self.image_file) + pre_processed = np.asarray(detection.pre_process(original)[:, :]) + samp = sample.CrossSampleFromCam(self.corners, pre_processed) + cropped = samp.crop() + cropped_file_path = 'cross-{0}-{1}.png'.format(self.label, + self.identifier) + cv2.imwrite(cropped_file_path, cropped.image) + cropped_cross = LabeledCross(self.label, cropped_file_path, + cropped.corners, + identifier=self.identifier) + return cropped_cross + + +def process_session(labeled_crosses, session_path): + session = sessiondb.SessionDB(session_path) + for exam in session.exams_iterator(): + image_file = session.get_raw_capture_path(exam['exam_id']) + all_cells = session._read_answer_cells(exam['exam_id']) + answers = session.read_answers(exam['exam_id']) + for cells, answer in zip(all_cells, answers): + crosses = [LabeledCross(0, image_file, + np.array([cell.plu, cell.pru, + cell.pld, cell.prd])) \ + for cell in cells] + if answer > 0: + crosses[answer - 1].label = 1 + for cross in crosses: + cropped_cross = cross.crop() + if cropped_cross is not None: + labeled_crosses[cropped_cross.label].append(cropped_cross) + session.close() + +def dump_cross_list(labeled_crosses): + with open('crosses.txt', 'a') as f: + for label in VALID_LABELS: + for labeled_cross in labeled_crosses[label]: + print(str(labeled_cross), file=f) + +def _initialize_crosses_dict(): + labeled_crosses = {} + for label in VALID_LABELS: + labeled_crosses[label] = [] + return labeled_crosses + +def main(): + logging.basicConfig(level=logging.INFO) + labeled_crosses = _initialize_crosses_dict() + for session_path in sys.argv[1:]: + logging.info('Processing session {}'.format(session_path)) + process_session(labeled_crosses, session_path) + dump_cross_list(labeled_crosses) + +if __name__ == '__main__': + main() diff --git a/eyegrade/experiments/extract_digits.py b/eyegrade/experiments/extract_digits.py index d10d0eab..9d18aa4e 100644 --- a/eyegrade/experiments/extract_digits.py +++ b/eyegrade/experiments/extract_digits.py @@ -19,75 +19,46 @@ import sys import random +import logging -# Import the cv module. It might be cv2.cv in newer versions. -try: - import cv -except ImportError: - import cv2.cv as cv +import numpy as np +import cv2 from .. import sessiondb -from .. import imageproc -from .. import capture -from .. import ocr +from .. import detection +from .. import images +from ..ocr import sample class LabeledDigit(object): - def __init__(self, digit, image_file, cell_geometry, identifier=None): + def __init__(self, digit, image_file, corners, identifier=None): self.digit = digit self.image_file = image_file - self.cell_geometry = cell_geometry + self.corners = corners if identifier is not None: self.identifier = identifier else: self.identifier = random.randint(0, 1000000000000) def __str__(self): - data = [ - self.image_file, str(self.digit), - str(self.cell_geometry.plu[0]), str(self.cell_geometry.plu[1]), - str(self.cell_geometry.pru[0]), str(self.cell_geometry.pru[1]), - str(self.cell_geometry.pld[0]), str(self.cell_geometry.pld[1]), - str(self.cell_geometry.prd[0]), str(self.cell_geometry.prd[1]), - ] + data = [self.image_file, str(self.digit)] + data.extend(str(n) for n in self.corners.reshape(8).tolist()) return '\t'.join(data) def crop(self): - original = imageproc.load_image(self.image_file) - pre_processed = imageproc.pre_process(original) - plu, pru, pld, prd = ocr.adjust_cell_corners(pre_processed, - (self.cell_geometry.plu, - self.cell_geometry.pru, - self.cell_geometry.pld, - self.cell_geometry.prd)) - total, active = imageproc.count_pixels_in_cell(pre_processed, - plu, pru, pld, prd) + original = images.load_image(self.image_file) + pre_processed = np.asarray(detection.pre_process(original)[:, :]) + samp = sample.DigitSampleFromCam(self.corners, pre_processed) + cropped = samp.crop() + total = cropped.image.shape[0] * cropped.image.shape[1] + active = sum(sum(cropped.image > 0)) if (active / total >= 0.01): - min_x = min(plu[0], pld[0]) - max_x = max(pru[0], prd[0]) - min_y = min(plu[1], pru[1]) - max_y = max(pld[1], prd[1]) - offset_x = min_x - offset_y = min_y - width = max_x - offset_x - height = max_y - offset_y - cropped = cv.CreateImage((width, height), pre_processed.depth, 1) - region = cv.GetSubRect(pre_processed, - (offset_x, offset_y, width, height)) - cv.Copy(region, cropped) cropped_file_path = 'digit-{0}-{1}.png'.format(self.digit, self.identifier) - imageproc.save_image(cropped_file_path, cropped) - new_geometry = capture.CellGeometry( - (plu[0] - offset_x, plu[1] - offset_y), - (pru[0] - offset_x, pru[1] - offset_y), - (pld[0] - offset_x, pld[1] - offset_y), - (prd[0] - offset_x, prd[1] - offset_y), - None, None - ) + cv2.imwrite(cropped_file_path, cropped.image) cropped_digit = LabeledDigit(self.digit, cropped_file_path, - new_geometry, + cropped.corners, identifier=self.identifier) else: cropped_digit = None @@ -101,7 +72,8 @@ def process_session(labeled_digits, session_path): cells = session._read_id_cells(exam['exam_id']) if exam['student_id'] and len(exam['student_id']) == len(cells): for digit, cell in zip(exam['student_id'], cells): - labeled_digit = LabeledDigit(int(digit), image_file, cell) + corners = np.array([cell.plu, cell.pru, cell.pld, cell.prd]) + labeled_digit = LabeledDigit(int(digit), image_file, corners) cropped_digit = labeled_digit.crop() if cropped_digit is not None: labeled_digits[int(digit)].append(cropped_digit) @@ -121,8 +93,10 @@ def _initialize_digits_dict(): return labeled_digits def main(): + logging.basicConfig(level=logging.INFO) labeled_digits = _initialize_digits_dict() for session_path in sys.argv[1:]: + logging.info('Processing session {}'.format(session_path)) process_session(labeled_digits, session_path) dump_digit_list(labeled_digits) diff --git a/eyegrade/eyegrade.py b/eyegrade/eyegrade.py index 78e47bed..e33393e8 100644 --- a/eyegrade/eyegrade.py +++ b/eyegrade/eyegrade.py @@ -15,7 +15,7 @@ # along with this program. If not, see # . # -from __future__ import division +from __future__ import division, print_function # The gettext module needs in Windows an environment variable # to be defined before importing the gettext module itself @@ -34,7 +34,7 @@ import gettext # Local imports -from . import imageproc +from . import detection from . import utils from .qtgui import gui from . import sessiondb @@ -65,30 +65,6 @@ capture_change_period_failure = 0.3 after_removal_delay = 1.0 -def cell_clicked(image, point): - min_dst = None - clicked_row = None - clicked_col = None - for i, row in enumerate(image.centers): - for j, center in enumerate(row): - dst = imageproc.distance(point, center) - if min_dst is None or dst < min_dst: - min_dst = dst - clicked_row = i - clicked_col = j - if (min_dst is not None and - min_dst <= image.diagonals[clicked_row][clicked_col] / 2): - return (clicked_row, clicked_col + 1) - else: - return None - -def select_camera(options, config): - if options.camera_dev is None: - camera = config['camera-dev'] - else: - camera = options.camera_dev - return camera - class ImageDetectTask(object): """Used for running image detection in another thread.""" @@ -112,10 +88,11 @@ def run(self): class ManualDetectionManager(object): - def __init__(self, exam, dimensions, detector_options): + def __init__(self, exam, dimensions, detection_context, detector_options): self.exam = exam self.points = [] - self.detector = imageproc.ExamDetector(dimensions, None, + self.detector = detection.ExamDetector(dimensions, + detection_context, detector_options, image_raw=exam.capture.image_raw) @@ -201,8 +178,8 @@ def __init__(self, interface, session_file=None): self.mode = ProgramMode() self.config = utils.config self.sessiondb = None - self.imageproc_context = self._get_imageproc_context() - self.imageproc_options = None + self.detection_context = self._get_detection_context() + self.detection_options = None self.drop_next_capture = False self.dump_buffer = False self._register_listeners() @@ -215,13 +192,13 @@ def run(self): """Starts the program manager.""" self.interface.run() - def _get_imageproc_context(self): + def _get_detection_context(self): false_detector_session = os.getenv('EYEGRADE_CAMERA_SESSION') if not false_detector_session: - return imageproc.ExamDetectorContext( \ + return detection.ExamDetectorContext( \ camera_id=self.config['camera-dev']) else: - return imageproc.FalseExamDetectorContext(false_detector_session) + return detection.FalseExamDetectorContext(false_detector_session) def _try_session_file(self, session_file): if os.path.isdir(session_file): @@ -248,7 +225,7 @@ def _start_search_mode(self): self.latest_detector = None self.manual_detect_manager = None self.interface.register_timer(50, self._next_search) - self.imageproc_context.dump_buffer(1.0) + self.detection_context.dump_buffer(1.0) self.next_capture = time.time() + 0.05 def _start_review_mode(self): @@ -285,17 +262,18 @@ def _start_manual_detect_mode(self): self.interface.display_capture(self.exam.get_image_drawn()) self.manual_detect_manager = \ ManualDetectionManager(self.exam, self.exam_data.dimensions, - self.imageproc_options) + self.detection_context, + self.detection_options) def _next_search(self): if not self.mode.in_search(): return if self.dump_buffer: self.dump_buffer = False - self.imageproc_context.dump_buffer(after_removal_delay) - detector = imageproc.ExamDetector(self.exam_data.dimensions, - self.imageproc_context, - self.imageproc_options) + self.detection_context.dump_buffer(after_removal_delay) + detector = detection.ExamDetector(self.exam_data.dimensions, + self.detection_context, + self.detection_options) self.current_detector = detector task = ImageDetectTask(detector) self.interface.run_worker(task, self._after_image_detection) @@ -308,8 +286,8 @@ def _after_image_detection(self): return self.latest_detector = detector if (detector.status['boxes'] - and self.imageproc_context.threshold_locked): - self.imageproc_context.unlock_threshold() + and self.detection_context.threshold_locked): + self.detection_context.unlock_threshold() exam = self._process_capture(detector) if exam is None or not detector.success: if exam is not None: @@ -344,10 +322,10 @@ def _next_change_detection(self): if (not self.mode.in_review_from_grading() or not self.interface.is_action_checked(('tools', 'auto_change'))): return - self.imageproc_context.dump_buffer(1.0) - detector = imageproc.ExamDetector(self.exam_data.dimensions, - self.imageproc_context, - self.imageproc_options) + self.detection_context.dump_buffer(1.0) + detector = detection.ExamDetector(self.exam_data.dimensions, + self.detection_context, + self.detection_options) self.current_detector = detector task = ImageChangeTask(detector, self.exam.capture) self.interface.run_worker(task, self._after_change_detection) @@ -380,7 +358,7 @@ def _after_change_detection(self): if not exam_removed: self._schedule_next_capture(period, self._next_change_detection) else: - self.imageproc_context.lock_threshold() + self.detection_context.lock_threshold() self.drop_next_capture = True self._action_continue() @@ -394,7 +372,7 @@ def _schedule_next_capture(self, period, function): current_time = time.time() self.next_capture += period if current_time > self.next_capture: - self.imageproc_context.dump_buffer((current_time + self.detection_context.dump_buffer((current_time - self.next_capture)) wait = 0.010 self.next_capture = time.time() + 0.010 @@ -505,7 +483,7 @@ def _close_session(self): self.mode.enter_no_session() self.sessiondb = None self.exam_data = None - self.imageproc_options = None + self.detection_options = None self.interface.activate_no_session_mode() def _exit_application(self): @@ -618,7 +596,7 @@ def _action_edit_id(self): def _action_camera_selection(self): """Callback for opening the camera selection dialog.""" - self.interface.dialog_camera_selection(self.imageproc_context) + self.interface.dialog_camera_selection(self.detection_context) def _action_help(self): """Callback for the help action.""" @@ -634,12 +612,12 @@ def _action_source_code(self): def _action_debug_changed(self): """Callback for the checkable actions in the debug options menu.""" - if self.imageproc_options is not None: - self.imageproc_options['show-lines'] = \ + if self.detection_options is not None: + self.detection_options['show-lines'] = \ self.interface.is_action_checked(('tools', 'lines')) - self.imageproc_options['show-image-proc'] = \ + self.detection_options['show-image-proc'] = \ self.interface.is_action_checked(('tools', 'processed')) - self.imageproc_options['show-status'] = \ + self.detection_options['show-status'] = \ self.interface.is_action_checked(('tools', 'show_status')) def _action_auto_change_changed(self): @@ -688,19 +666,21 @@ def _mouse_pressed(self, point): self._mouse_pressed_manual_detection(point) def _mouse_pressed_change_answer(self, point): - question, answer = self.exam.capture.get_cell_clicked(point) - if question is not None: - self.exam.toggle_answer(question, answer) - self.interface.display_capture(self.exam.get_image_drawn()) - self.interface.update_status(self.exam.score, + if self.exam.capture.has_answer_cells(): + question, answer = self.exam.capture.get_cell_clicked(point) + if question is not None: + self.exam.toggle_answer(question, answer) + self.interface.display_capture(self.exam.get_image_drawn()) + self.interface.update_status(self.exam.score, self.exam.decisions.model, self.exam.exam_id, survey_mode=self.exam_data.survey_mode) - self.sessiondb.update_answer(self.exam.exam_id, question, + self.sessiondb.update_answer(self.exam.exam_id, question, self.exam.capture, self.exam.decisions, self.exam.score, store_captures=False) - self.interface.run_later(self._store_capture_and_update, delay=100) + self.interface.run_later(self._store_capture_and_update, + delay=100) def _mouse_pressed_manual_detection(self, point): manager = self.manual_detect_manager @@ -747,16 +727,17 @@ def _activate_session_mode(self): def _start_grading(self): exam_data = self.exam_data - self.imageproc_options = imageproc.ExamDetector.get_default_options() + self.detection_options = detection.ExamDetector.get_default_options() + self.detection_options['error-logging'] = self.config['error-logging'] if exam_data.id_num_digits and exam_data.id_num_digits > 0: - self.imageproc_options['read-id'] = True - self.imageproc_options['id-num-digits'] = exam_data.id_num_digits - self.imageproc_options['left-to-right-numbering'] = \ + self.detection_options['read-id'] = True + self.detection_options['id-num-digits'] = exam_data.id_num_digits + self.detection_options['left-to-right-numbering'] = \ exam_data.left_to_right_numbering - # Set the debug options in imageproc_options: + # Set the debug options in detection_options: self._action_debug_changed() - self.imageproc_context.open_camera() - if self.imageproc_context.camera is None: + self.detection_context.open_camera() + if self.detection_context.camera is None: self.interface.show_error(_('No camera found. Connect a camera and ' 'start the session again.')) return @@ -766,7 +747,7 @@ def _start_grading(self): def _stop_grading(self): if self.mode.in_grading(): - self.imageproc_context.close_camera() + self.detection_context.close_camera() self._activate_session_mode() def _store_capture_and_add(self): @@ -835,16 +816,17 @@ def main(): utils.qt_translations_dir()) app.installTranslator(translator) if len(sys.argv) >= 2: - filename = sys.argv[1] + filename = utils.path_to_unicode(sys.argv[1]) else: filename = None - interface = gui.Interface(app, False, False, []) - manager = ProgramManager(interface, session_file=filename) - manager.run() - -if __name__ == '__main__': try: - main() + interface = gui.Interface(app, False, False, [], + preferred_styles=utils.config['gui-styles']) + manager = ProgramManager(interface, session_file=filename) + manager.run() except utils.EyegradeException as ex: - print >>sys.stderr, ex + print(unicode(ex).encode(sys.stdout.encoding)) sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/eyegrade/images.py b/eyegrade/images.py new file mode 100644 index 00000000..17d6a101 --- /dev/null +++ b/eyegrade/images.py @@ -0,0 +1,95 @@ +# Eyegrade: grading multiple choice questions with a webcam +# Copyright (C) 2010-2015 Jesus Arias Fisteus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see +# . +# + +from __future__ import division + +import math + +import cv2 +import numpy as np + +from . import geometry as g +from . import utils + + +# Main image processing functions on numpy images +# +def width(image): + """Returns the width of the numpy image in pixels.""" + return image.shape[1] + +def height(image): + """Returns the height of the numpy image in pixels.""" + return image.shape[0] + +def new_image(width, height, num_channels): + if num_channels == 1: + image = np.zeros((height, width), np.uint8) + elif num_channels == 3: + image = np.zeros((height, width, 3), np.uint8) + else: + raise ValueError('Wrong number of channels in _new_image()') + return image + +def zero_image(image): + image[:, :] = 0 + +def gray_to_rgb(image): + return cv2.cvtColor(image, cv2.COLOR_GRAY2RGB) + +def rgb_to_gray(image): + return cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) + + +# Image reading and writing +# +def load_image_grayscale(filename): + return load_image(filename, flags=cv2.IMREAD_GRAYSCALE) + +def load_image(filename, **kwargs): + if isinstance(filename, unicode): + filename = utils.unicode_path_to_str(filename) + return cv2.imread(filename, **kwargs) + + +# Drawing functions +# +def draw_line(image, line, color=(0, 0, 255, 0)): + theta = line[1] + points = set() + if math.sin(theta) != 0.0: + points.add(g.line_point(line, x=0)) + points.add(g.line_point(line, x=width(image) - 1)) + if math.cos(theta) != 0.0: + points.add(g.line_point(line, y=0)) + points.add(g.line_point(line, y=height(image) - 1)) + p_draw = [p for p in points if p[0] >= 0 and p[1] >= 0 + and p[0] < width(image) and p[1] < height(image)] + if len(p_draw) == 2: + cv2.line(image, p_draw[0], p_draw[1], color, thickness=1) + +def draw_point(image, point, color=(255, 0, 0, 0), radius=2): + x, y = point + if x >= 0 and x < width(image) and y >= 0 and y < height(image): + cv2.circle(image, point, radius, color, thickness=-1) + else: + print "draw_point: bad point (%d, %d)"%(x, y) + +def draw_text(image, text, color=(255, 0, 0), position=(10, 30)): + cv2.putText(image, text, position, cv2.FONT_HERSHEY_SIMPLEX, 1.0, color, + thickness=3) diff --git a/eyegrade/ocr.py b/eyegrade/ocr.py deleted file mode 100644 index 1da7a01e..00000000 --- a/eyegrade/ocr.py +++ /dev/null @@ -1,309 +0,0 @@ -# Eyegrade: grading multiple choice questions with a webcam -# Copyright (C) 2010-2015 Jesus Arias Fisteus -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see -# . -# - -# OCR for hand-written digits -# -import tre - -from . import geometry as g - -param_cross_num_lines = 15 -param_cell_margin = 2 -param_max_errors = 4 - -# Tre initializations -tre_fz = tre.Fuzzyness(maxerr = param_max_errors) -regexps = [(r'^1{0,2}222+1{0,2}$', # zero - r'^1{0,2}222+1{0,2}$', - r'^/(XXX/|X._/|_.X/|.X./)+X_X/(X_X/)+(XXX/|X._/|_.X/|.X./)+$', - r'^/(XXX/|X._/|_.X/|.X./)+X_X/(X_X/)+(XXX/|X._/|_.X/|.X./)+$'), - (r'^11+(22+1+|211+|111+)11+$', # one - r'^1+$|^1{0,2}2+11+$', - r'^/(_.X/|_X./)(_.X/|_X./)+(X.X/)*(_X_/|__X/|XX_/)+' - + r'(.XX/|XX./){0,2}$', - r'(XXX/|XX_/_XX)'), - (r'^1+2{0,4}111+2{0,2}11{0,2}$', # two - r'^1{0,3}2*3+2+1{0,3}$', - r'^/(.../){0,3}(__X/)*(_X./)+(X._/)+(.XX/|XX./|X_X/)+(.../){0,2}$', - r'^/(._X/|X_./)+(XXX/)+(._X/|X_./)+(._./)*$'), - (r'^1+2{0,2}11+2?11+2{0,2}1+$', # three - r'^1*(22+3+|2+33+|333+)(44?2+)?2{0,2}1{0,2}$', - r'^/(X._/|_.X/|XXX/)(X._/|_.X/|XXX/){0,2}(X_X){0,2}(_.X/|_X./)+' - + r'(X._/){0,2}(XX./|.XX/)+(X._/){0,2}(_.X/)+(X_X/){0,2}' - + r'(X._/|_.X/|XXX/)(X._/|_.X/|XXX/){0,2}$', - r'^/(._X/|X_./)*(XXX/)(XXX/)+(X../|.X./|..X/)*$'), - (r'^1{0,2}22+111+$', # four - r'^11?(22?1|11+)(22?|1+)1+$', - r'^/(X_./|._X/)(X_./|._X/)+(X_X/)+(XX./|.XX/)+(..X/)+(.X./){0,3}$', - r'^/(X._/|.X_/)+(.X./)(.X./)+(.XX/|XX./)+$'), - (r'^1+2{0,2}1+2{0,2}1+2{0,3}1+$', # five - r'^1?2{0,2}(2|3)33+(1|2){0,4}$', - r'^/(XX./|.XX/|X._/|_.X/)+(X__/)+(XX./|.XX/){0,3}(__X/)+' - + r'(X_X/)*(.XX/|XX./|X__/|__X/)+$', - r'^/(.../)+(XXX/)(XXX/)+(X../|.XX/)+(_X_/|__X/){0,2}$'), - (r'^1{0,2}2{0,3}111+2{0,2}3{0,2}22+1{0,2}$', # six - r'^1{0,2}2{0,2}333+2{0,2}1{0,2}$', - r'^/(.../){0,3}(X__/|_X_/)(X__/|_X_/)+(X._/)+(.../)(.../){0,2}' - + '(X_X/)+(.../)(.../){0,2}$', - r'^/(.X./|..X/)+(XXX/)+(.../){0,3}$'), - (r'11?(22{0,2}|1+)2?1+2?11+', # seven - r'1+2(2|1)+11?', - r'^/(X../|..X/){0,3}(_X./|_.X/)+(.XX/)(.XX/)?(_X./|_.X/)' - + r'(_X./|_.X/)+(.X_/|X._/)*$', - r'^/(_X./)*(X._/)+(.XX/)+.*$'), - (r'^1{0,2}22+1+22+1{0,2}$', # eight - r'^1{0,2}2{0,2}333+2{0,2}1{0,2}$', - r'^/(.../)(.../){0,3}(X_X/)+(.../)*(.X_/|_X./|X__/|__X/)+' - + r'(.../)*(X_X/)+', - r'(XXX/)(XXX/)(XXX/)'), - (r'^1{0,2}22+3?2?12?11+$', # nine - r'^1{0,2}(2+3+2*|222+)1+$', - r'^/(.../)+(X_X/)+(_XX/|XXX/)(_XX/|XXX/)?(_.X/)+(_X./)*$', - r'^/(X._/|.X_/)(X._/|.X_/)+(XX./|.XX/)+$')] -re_compiled = [] -for row in regexps: - re_compiled.append((tre.compile(row[0], tre.EXTENDED), - tre.compile(row[1], tre.EXTENDED), - tre.compile(row[2], tre.EXTENDED), - tre.compile(row[3], tre.EXTENDED))) - -# limits: for each digit (min_len_num_hcrossings, min_len_num_vcrossings, -# max_num_hcrossings, max_num_vcrossings, -# min_num_hcrossings, min_num_vcrossings, -# min_max_num_hcrossings, min_max_num_vcrossings) -limits = [(4, 4, 3, 3, 1, 1, 2, 2), # zero - (4, 1, 2, 2, 1, 1, 1, 1), # one - (4, 4, 3, 3, 1, 1, 1, 2), # two - (4, 4, 2, 4, 1, 1, 1, 3), # three - (4, 4, 3, 2, 1, 1, 2, 1), # four - (4, 4, 2, 3, 1, 1, 1, 3), # five - (4, 4, 2, 3, 1, 1, 2, 2), # six - (4, 3, 2, 3, 1, 1, 1, 2), # seven - (4, 4, 2, 4, 1, 1, 2, 3), # eight - (4, 3, 2, 3, 1, 1, 2, 2)] # nine - -def digit_ocr(image, cell_corners, debug = None, image_drawn = None): - assert(not debug or image_drawn is not None) - return digit_ocr_by_line_crossing(image, cell_corners, debug, image_drawn) - -def digit_ocr_by_line_crossing(image, cell_corners, debug, image_drawn): - points = adjust_cell_corners(image, cell_corners) - plu, pru, pld, prd = points - points_left = g.interpolate_line(plu, pld, param_cross_num_lines) - points_right = g.interpolate_line(pru, prd, param_cross_num_lines) - points_up = g.interpolate_line(plu, pru, param_cross_num_lines) - points_down = g.interpolate_line(pld, prd, param_cross_num_lines) - hcrossings = [] - vcrossings = [] - for i in range(0, param_cross_num_lines): - h = float(i) / (param_cross_num_lines - 1) - hcrossings.append(crossings(image, points_left[i], points_right[i], - h, debug, image_drawn)) - vcrossings.append(crossings(image, points_up[i], points_down[i], - h, debug, image_drawn)) - if debug: - print hcrossings - print vcrossings - return decide_digit(hcrossings, vcrossings, debug) - -def decide_digit(hcrossings, vcrossings, debug = False): - decision = None - hcrossings = __trim_empty_lists(hcrossings) - vcrossings = __trim_empty_lists(vcrossings) - if len(hcrossings) > 0 and len(vcrossings) > 0: - num_hcrossings = [len(l) for l in hcrossings] - num_vcrossings = [len(l) for l in vcrossings] - if debug: - print num_hcrossings - print num_vcrossings - hstr = ''.join([str(v) for v in num_hcrossings]) - vstr = ''.join([str(v) for v in num_vcrossings]) - signatures = crossings_signatures(hcrossings, vcrossings) - if debug: - print signatures - scores = [] - for i in range(0, 10): - max_num_hcrossings = max(num_hcrossings) - max_num_vcrossings = max(num_vcrossings) - if min(num_hcrossings) < limits[i][4] \ - or min(num_vcrossings) < limits[i][5] \ - or len(num_hcrossings) < limits[i][0] \ - or len(num_vcrossings) < limits[i][1]: - p = 0.0 - else: - mhcscore = min_max_crossings_score(limits[i][6], limits[i][2], - max_num_hcrossings) - mvcscore = min_max_crossings_score(limits[i][7], limits[i][3], - max_num_vcrossings) - hnmatch = re_compiled[i][0].search(hstr, tre_fz) - vnmatch = re_compiled[i][1].search(vstr, tre_fz) - hnscore = max(1.0 - 0.25 * hnmatch.cost \ - if hnmatch else 0.2, 0.2) - vnscore = max(1.0 - 0.25 * vnmatch.cost \ - if vnmatch else 0.2, 0.2) - hpmatch = re_compiled[i][2].search(signatures[0], tre_fz) - vpmatch = re_compiled[i][3].search(signatures[1], tre_fz) - hpscore = max(1.0 - 0.2 * hpmatch.cost if hpmatch else 0.4, 0.4) - vpscore = max(1.0 - 0.2 * vpmatch.cost if vpmatch else 0.4, 0.4) - if debug: - print i, hnscore, vnscore, hpscore, vpscore, \ - mhcscore, mvcscore - p = hnscore * vnscore * hpscore * vpscore * mhcscore * mvcscore - scores.append((p, i)) - if debug: - print sorted(scores, reverse = True) - m = max(scores) - if m[0] > 0.0: - decision = m[1] - else: - scores = [(0.0, i) for i in range(10)] - return decision, [score[0] for score in scores] - -def crossings(image, p0, p1, h, debug = False, image_drawn = None): - pixels = [] - crossings = [] - for x, y in g.walk_line(p0, p1): - pixels.append(image[y, x] > 0) - # Filter the value sequence - for i in range(1, len(pixels) - 1): - if not pixels[i - 1] and not pixels[i + 1]: - pixels[i] = False - elif pixels[i - 1] and pixels[i + 1]: - pixels[i] = True - # detect crossings - begin = None - for i, value in enumerate(pixels): - if begin is None: - if value: - begin = i - else: - if not value or i == len(pixels) - 1: - end = i if value else i - 1 - if end - begin > 0: - n = len(pixels) - begin_rel = float(begin) / n - end_rel = float(end) / n - center_rel = (begin_rel + end_rel) / 2 - length = end - begin + 1 - crossings.append((begin_rel, end_rel, - center_rel, length, h)) - begin = None - return crossings - -# Other auxiliary functions -# -def crossings_signatures(hcrossings, vcrossings): - min_length_px = min([min([v[3] for v in c]) \ - for c in hcrossings + vcrossings \ - if len(c) > 0]) - width_threshold = 3 * min_length_px - min_v = hcrossings[0][0][4] - max_v = hcrossings[-1][0][4] - min_h = vcrossings[0][0][4] - max_h = vcrossings[-1][0][4] - signatures = [] - for crossings, min_pos, max_pos \ - in (hcrossings, min_h, max_h), (vcrossings, min_v, max_v): - region_width = (max_pos - min_pos) / 3 - if region_width < 0.1: - region_width = 0.1 - limits = (max_pos - 2 * region_width, max_pos - region_width) - particles = [''] - for row in crossings: - mark = [False, False, False] - for c in row: - m = [False, False, False] - m[0] = c[0] < limits[0] - m[1] = (c[0] < limits[1] and c[1] >= limits[0]) - m[2] = c[1] >= limits[1] - if len(row) <= 2 and c[3] <= width_threshold \ - and c[1] - c[0] < region_width \ - and (c[0] < limits[0] or c[1] >= limits[1]): - m[1] = False - for i in range(3): - mark[i] = mark[i] or m[i] - particles.append(''.join(['X' if mm else '_' for mm in mark])) - particles.append('') - signatures.append('/'.join(particles)) - return signatures - -def __trim_empty_lists(lists): - """Receives a list of lists. Returns a new list of lists without - any empty lists at the beginning or end of it.""" - if len(lists) == 0: - return [] - begin = -1 - end = 0 - for i in range(0, len(lists)): - if len(lists[i]) > 0: - end = i + 1 - if begin == -1: - begin = i - # Filter noise at the borders of the cell - if (len(lists[begin]) == 1 and end - begin > 1 \ - and len(lists[begin + 1]) == 0 and lists[begin][0][3] < 6) \ - or (end - begin > 2 and len(lists[begin + 1]) == 0 \ - and len(lists[begin + 2]) == 0): - begin += 1 - while begin < end and len(lists[begin]) == 0: - begin += 1 - if (len(lists[end - 1]) == 1 and end >= 2 and len(lists[end - 2]) == 0 \ - and lists[end - 1][0][3] < 6) \ - or (end >= 3 and len(lists[end - 2]) == 0 \ - and len(lists[end - 3]) == 0): - end -= 1 - while end >= 1 and len(lists[end - 1]) == 0: - end -= 1 - return lists[begin:end] - -def min_max_crossings_score(min_max_crossings, max_max_crossings, - actual_max_crossings): - d = min_max_crossings - actual_max_crossings - if d == 1: - score = 0.5 - elif d > 1: - score = 0.1 - else: - score = 1.0 - d = max_max_crossings - actual_max_crossings - if d == -1: - score = score * 0.5 - elif d < -1: - score = score * 0.1 - return score - -def adjust_cell_corners(image, corners): - plu, pru, pld, prd = corners - plu = adjust_cell_corner(image, plu, prd) - prd = adjust_cell_corner(image, prd, plu) - pru = adjust_cell_corner(image, pru, pld) - pld = adjust_cell_corner(image, pld, pru) - return(plu, pru, pld, prd) - -def adjust_cell_corner(image, corner, towards_corner): - margin = None - for x, y in g.walk_line_ordered(corner, towards_corner): - if margin is None: - if image[y, x] == 0: - margin = param_cell_margin - else: - margin -= 1 - if margin == 0: - return (x, y) - # In case of failure, return the original point - return corner diff --git a/eyegrade/ocr/__init__.py b/eyegrade/ocr/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/eyegrade/ocr/classifiers.py b/eyegrade/ocr/classifiers.py new file mode 100644 index 00000000..75d65ed2 --- /dev/null +++ b/eyegrade/ocr/classifiers.py @@ -0,0 +1,136 @@ +# Eyegrade: grading multiple choice questions with a webcam +# Copyright (C) 2015 Rodrigo Arguello, Jesus Arias Fisteus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see +# . +# +import json +import os.path + +import cv2 +import numpy as np + +from . import preprocessing +from .. import utils + + +DEFAULT_DIG_CLASS_FILE = 'digit_classifier.dat.gz' +DEFAULT_DIG_META_FILE = 'digit_classifier_metadata.txt' +DEFAULT_CROSS_CLASS_FILE = 'cross_classifier.dat.gz' +DEFAULT_CROSS_META_FILE = 'cross_classifier_metadata.json' +DEFAULT_DIR = 'svm' + +class SVMClassifier(object): + def __init__(self, num_classes, features_extractor, load_from_file=None): + self.num_classes = num_classes + self.features_extractor = features_extractor + self.svm = cv2.SVM() + if load_from_file: + self.svm.load(SVMClassifier.resource(load_from_file)) + + @property + def features_len(self): + return self.features_extractor.features_len + + def train(self, samples, params=None): + features = np.ndarray(shape=(len(samples), self.features_len), + dtype='float32') + labels = np.ndarray(shape=(len(samples), 1), dtype='float32') + for i, sample in enumerate(samples): + features[i,:] = self.features_extractor.extract(sample) + labels[i] = float(sample.label) + svm_params = dict(kernel_type=cv2.SVM_RBF, + svm_type=cv2.SVM_C_SVC, + C=10, + gamma=0.01) + if params: + if 'C' in params: + svm_params['C'] = params['C'] + if 'gamma' in params: + svm_params['gamma'] = params['gamma'] + self.svm.train(features, labels, params=svm_params) + + def classify(self, sample): + features = self.features_extractor.extract(sample) + return int(round(self.svm.predict(features))) + + def reset(self): + self.svm = cv2.SVM() + + def save(self, filename): + self.svm.save(filename) + + @staticmethod + def resource(filename): + return utils.resource_path(os.path.join(DEFAULT_DIR, filename)) + + +class SVMDigitClassifier(SVMClassifier): + def __init__(self, features_extractor, load_from_file=None, + confusion_matrix_from_file=None): + super(SVMDigitClassifier, self).__init__(10, features_extractor, + load_from_file=load_from_file) + self.confusion_matrix = \ + self._load_confusion_matrix(confusion_matrix_from_file) + + def classify_digit(self, sample): + digit = self.classify(sample) + weights = self.confusion_matrix[:, digit] + return (digit, weights) + + @staticmethod + def _load_confusion_matrix(filename): + if filename: + with open(SVMClassifier.resource(filename)) as f: + metadata = json.load(f) + matrix = np.array(metadata['confusion_matrix'], dtype=float) + else: + matrix = np.diag(np.ones(10, dtype=float)) + return matrix + + +class DefaultDigitClassifier(SVMDigitClassifier): + def __init__(self, + load_from_file=DEFAULT_DIG_CLASS_FILE, + confusion_matrix_from_file=DEFAULT_DIG_META_FILE): + super(DefaultDigitClassifier, self).__init__( \ + preprocessing.FeatureExtractor(), + load_from_file=load_from_file, + confusion_matrix_from_file=confusion_matrix_from_file) + + def train(self, samples, params=None): + super(DefaultDigitClassifier, self).train( \ + samples, + dict(C=3.16227766, gamma=0.01)) + + +class SVMCrossesClassifier(SVMClassifier): + def __init__(self, features_extractor, load_from_file=None): + super(SVMCrossesClassifier, self).__init__(2, features_extractor, + load_from_file=load_from_file) + + def is_cross(self, sample): + return self.classify(sample) == 1 + + +class DefaultCrossesClassifier(SVMCrossesClassifier): + def __init__(self, load_from_file=DEFAULT_CROSS_CLASS_FILE): + super(DefaultCrossesClassifier, self).__init__( \ + preprocessing.CrossesFeatureExtractor(), + load_from_file=load_from_file) + + def train(self, samples, params=None): + super(DefaultCrossesClassifier, self).train( \ + samples, + dict(C=100, gamma=0.01)) diff --git a/eyegrade/ocr/create_classifier.py b/eyegrade/ocr/create_classifier.py new file mode 100644 index 00000000..fe98cb75 --- /dev/null +++ b/eyegrade/ocr/create_classifier.py @@ -0,0 +1,106 @@ +# Eyegrade: grading multiple choice questions with a webcam +# Copyright (C) 2015 Jesus Arias Fisteus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see +# . +# +from __future__ import unicode_literals + +import json +import argparse + +from . import sample +from . import classifiers +from . import evaluation + + +def save_metadata(filename, metadata): + with open(filename, mode='w') as f: + json.dump(metadata, f, indent=4, sort_keys=True) + +def k_fold_cross_evaluation(classifier, sample_set, rounds): + classifier.reset() + partitions = sample_set.partition(rounds) + e = evaluation.KFoldCrossEvaluation(classifier, partitions) + return e + +def train_with_all(classifier, sample_set): + classifier.reset() + classifier.train(sample_set.samples()) + +def create_digit_classifier(sample_set, rounds): + classifier = classifiers.DefaultDigitClassifier( \ + load_from_file=None, + confusion_matrix_from_file=None) + e = k_fold_cross_evaluation(classifier, sample_set, rounds) + metadata = { + 'performance': { + 'success_rate': e.success_rate, + 'balanced_success_rate': e.success_rate_balanced, + 'evaluation_rounds': rounds, + 'num_samples': len(sample_set), + }, + 'confusion_matrix': e.confusion_matrix_r.tolist(), + } + print('Success rate: {} (balanced: {})'.format(e.success_rate, + e.success_rate_balanced)) + save_metadata(classifiers.DEFAULT_DIG_META_FILE, metadata) + train_with_all(classifier, sample_set) + classifier.save(classifiers.DEFAULT_DIG_CLASS_FILE) + +def create_crosses_classifier(sample_set, rounds): + classifier = classifiers.DefaultCrossesClassifier(load_from_file=None) + e = k_fold_cross_evaluation(classifier, sample_set, rounds) + print('Success rate: {} (balanced: {})'.format(e.success_rate, + e.success_rate_balanced)) + metadata = { + 'performance': { + 'success_rate': e.success_rate, + 'balanced_success_rate': e.success_rate_balanced, + 'evaluation_rounds': rounds, + 'num_samples': len(sample_set), + }, + 'confusion_matrix': e.confusion_matrix_r.tolist(), + } + save_metadata(classifiers.DEFAULT_CROSS_META_FILE, metadata) + train_with_all(classifier, sample_set) + classifier.save(classifiers.DEFAULT_CROSS_CLASS_FILE) + +def _parse_args(): + parser = argparse.ArgumentParser(description='Generate CSV data files.') + parser.add_argument('classifier', + help='classifier to be created ("digits" or "crosses")') + parser.add_argument('sample_files', metavar='sample file', nargs='+', + help='index file with the samples for training/evaluation') + parser.add_argument('--rounds', type=int, default=100, + help='number of rounds for k-fold cross evaluation (default 100)') + return parser.parse_args() + +def main(): + args = _parse_args() + + # Load the sample set: + sample_set = sample.SampleSet() + for filename in args.sample_files: + sample_set.load_from_loader(sample.SampleLoader(filename)) + + # Perform a k-fold cross-evaluation and create the classifier: + if args.classifier == 'digits': + create_digit_classifier(sample_set, args.rounds) + else: + create_crosses_classifier(sample_set, args.rounds) + + +if __name__ == '__main__': + main() diff --git a/eyegrade/ocr/decide_params.py b/eyegrade/ocr/decide_params.py new file mode 100644 index 00000000..921dd78e --- /dev/null +++ b/eyegrade/ocr/decide_params.py @@ -0,0 +1,80 @@ +# Eyegrade: grading multiple choice questions with a webcam +# Copyright (C) 2016 Jesus Arias Fisteus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see +# . +# +from __future__ import unicode_literals + +import argparse +import math + +import numpy as np + +from . import sample +from . import classifiers +from . import evaluation + + +def decide_params(classifier, sample_set, c_values, gamma_values, + threshold=None, k=10): + results = [] + rmat = np.zeros(shape=(len(c_values), len(gamma_values)), dtype='float32') + partitions = sample_set.partition(k) + for i, c in enumerate(c_values): + for j, gamma in enumerate(gamma_values): + params = dict(C=c, gamma=gamma) + print('C: {}, gamma: {}'.format(c, gamma)) + e = evaluation.KFoldCrossEvaluation(classifier, partitions, + training_params=params, + threshold=threshold) + result = (e.success_rate, e.success_rate_balanced, c, gamma) + results.append(result) + rmat[i, j] = e.success_rate + print(result) + print(rmat) + return results, rmat + +def _parse_args(): + parser = argparse.ArgumentParser( \ + description='Look for the best SVM parameters.') + parser.add_argument('classifier', + help='classifier to be evaluated ("digits" or "crosses")') + parser.add_argument('sample_files', metavar='sample file', nargs='+', + help='index file with the samples for training/evaluation') + parser.add_argument('--rounds', type=int, default=10, + help='number of rounds for k-fold cross evaluation (default 10)') + return parser.parse_args() + +def main(): + args = _parse_args() + sample_set = sample.SampleSet() + for filename in args.sample_files: + sample_set.load_from_loader(sample.SampleLoader(filename)) + if args.classifier == 'digits': + classifier = classifiers.DefaultDigitClassifier( \ + load_from_file=None, + confusion_matrix_from_file=None) + threshold = 0.9 + else: + classifier = classifiers.DefaultCrossesClassifier(load_from_file=None) + threshold = 0.99 + c_values = [math.pow(10, i) for i in np.linspace(0, 4, 9)] + gamma_values = [math.pow(10, i) for i in np.linspace(-3, -1, 5)] + r = decide_params(classifier, sample_set, c_values, gamma_values, + threshold=threshold) + print(r) + +if __name__ == '__main__': + main() diff --git a/eyegrade/ocr/evaluation.py b/eyegrade/ocr/evaluation.py new file mode 100644 index 00000000..0c0a04a7 --- /dev/null +++ b/eyegrade/ocr/evaluation.py @@ -0,0 +1,82 @@ +# Eyegrade: grading multiple choice questions with a webcam +# Copyright (C) 2015 Jesus Arias Fisteus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see +# . +# +from __future__ import division + +import numpy as np + +from . import sample + + +class Evaluation(object): + def __init__(self, classifier, samples): + self.classifier = classifier + self.samples = samples + self._evaluate() + + @property + def confusion_matrix_r(self): + return (np.array(self.confusion_matrix, dtype='float32') + / np.sum(self.confusion_matrix, axis=1)[np.newaxis].T) + + @property + def success_rate_balanced(self): + return np.mean(self.confusion_matrix_r.diagonal()) + + def _evaluate(self): + num_classes = self.classifier.num_classes + self.results = np.zeros(len(self.samples), dtype=bool) + self.confusion_matrix = np.zeros(shape=(num_classes, num_classes), + dtype='int') + for i, samp in enumerate(self.samples): + detected = self.classifier.classify(samp) + self.confusion_matrix[samp.label, detected] += 1 + self.results[i] = samp.check_label(detected) + self.success_rate = sum(self.results) / len(self.results) + + +class KFoldCrossEvaluation(Evaluation): + def __init__(self, classifier, sample_sets, oversampling=False, + training_params=None, threshold=None): + self.classifier = classifier + self.sample_sets = sample_sets + self.training_params = training_params + self.threshold = threshold + self._evaluate(oversampling=oversampling) + + def _evaluate(self, oversampling=False): + num_classes = self.classifier.num_classes + self.confusion_matrix = np.zeros(shape=(num_classes, num_classes), + dtype='int') + for i, evaluation_set in enumerate(self.sample_sets): + training_set = sample.SampleSet() + training_set.load_from_sample_sets(self.sample_sets[:i]) + training_set.load_from_sample_sets(self.sample_sets[i + 1:]) + if oversampling: + training_set = training_set.oversample() + self.classifier.train(training_set.samples(), + params=self.training_params) + evaluation = Evaluation(self.classifier, evaluation_set) + self.confusion_matrix += evaluation.confusion_matrix + self.classifier.reset() + total = self.confusion_matrix.sum() + correct = self.confusion_matrix.diagonal().sum() + self.success_rate = correct / total + print('Round {}: {}'.format(i, self.success_rate)) + if (self.threshold is not None + and self.success_rate < self.threshold): + break diff --git a/eyegrade/ocr/preprocessing.py b/eyegrade/ocr/preprocessing.py new file mode 100644 index 00000000..6dddfad6 --- /dev/null +++ b/eyegrade/ocr/preprocessing.py @@ -0,0 +1,168 @@ +# Eyegrade: grading multiple choice questions with a webcam +# Copyright (C) 2015 Rodrigo Arguello, Jesus Arias Fisteus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see +# . +# + +import cv2 +import numpy as np +import numpy.linalg as linalg + + +class FeatureExtractor(object): + """Default feature extractor. + + It assumes that images contain just one digit + and ignores image corners. + + """ + def __init__(self, dim=28): + self.dim = dim + + def extract(self, sample): + image = self._reshape(sample) + image = deskew(image, self.dim) + image = clear_boundbox(image) + image = cv2.resize(image, (self.dim, self.dim)) + image_matrix = np.array(image, np.float32) / 255.0 + feature_vector = image_matrix.reshape(self.features_len, ) + return feature_vector + + @property + def features_len(self): + return self.dim * self.dim + + @staticmethod + def _project_to_rectangle(sample, width, height): + p = sample.corners + corners_dst = np.array([[0, 0], + [width - 1, 0], + [0, height - 1], + [width - 1, height - 1]], + dtype='float32') + h = cv2.findHomography(np.array(p, dtype='float32'), corners_dst) + image = cv2.warpPerspective(sample.image, h[0], (width, height)) + return cv2.threshold(image, 64, 255, cv2.THRESH_BINARY)[1] + + @staticmethod + def _reshape(sample): + p = sample.corners + width = int((cv2.norm(p[0,:], p[1,:]) + cv2.norm(p[2,:], p[3,:])) / 2) + height = int((cv2.norm(p[0,:], p[2,:]) + cv2.norm(p[1,:], p[3,:])) / 2) + return FeatureExtractor._project_to_rectangle(sample, width, height) + + +class CrossesFeatureExtractor(FeatureExtractor): + """Feature extractor for crosses. + + """ + def __init__(self, dim=28): + super(CrossesFeatureExtractor, self).__init__(dim=dim) + + def extract(self, sample): + image = self._project_to_rectangle(sample, self.dim, self.dim) +# image = cv2.resize(image, (self.dim, self.dim)) + image_matrix = np.array(image, np.float32) / 255.0 + feature_vector = image_matrix.reshape(self.features_len, ) + return feature_vector + + +class OpenCVExampleExtractor(object): + def __init__(self, dim=20, threshold=False): + self.dim = dim + self.threshold = threshold + self._corners_dst = np.array([[0, 0], + [dim - 1, 0], + [0, dim - 1], + [dim - 1, dim - 1]], + dtype='float32') + self.features_len = 64 + + def extract(self, sample): + corners = np.array(sample.corners, dtype='float32') + h = cv2.findHomography(corners, self._corners_dst) + image = cv2.warpPerspective(sample.image, h[0], (self.dim, self.dim)) + if self.threshold: + image = cv2.threshold(image, 64, 255, cv2.THRESH_BINARY)[1] + image = deskew(image, self.dim) + feature_vector = self._preprocess_hog(image) + return feature_vector + + def _preprocess_hog(self, image): + gx = cv2.Sobel(image, cv2.CV_32F, 1, 0) + gy = cv2.Sobel(image, cv2.CV_32F, 0, 1) + mag, ang = cv2.cartToPolar(gx, gy) + bin_n = 16 + bin_ = np.int32(bin_n * ang / (2 * np.pi)) + bin_cells = bin_[:10,:10], bin_[10:,:10], bin_[:10,10:], bin_[10:,10:] + mag_cells = mag[:10,:10], mag[10:,:10], mag[:10,10:], mag[10:,10:] + hists = [np.bincount(b.ravel(), m.ravel(), bin_n) \ + for b, m in zip(bin_cells, mag_cells)] + hist = np.hstack(hists) + # transform to Hellinger kernel + eps = 1e-7 + hist /= hist.sum() + eps + hist = np.sqrt(hist) + hist /= linalg.norm(hist) + eps + return np.float32(hist) + + +def deskew(image, dim): + """Deskew an image. + + It improves classifier performance. + The image must be a cv2 image. + + """ + affine_flags = cv2.WARP_INVERSE_MAP | cv2.INTER_LINEAR + m = cv2.moments(image) + if abs(m['mu02']) < 1e-2: + return image.copy() + skew = m['mu11'] / m['mu02'] + M = np.float32([[1, skew, -0.5 * dim * skew], [0, 1, 0]]) + image = cv2.warpAffine(image, M, (dim, dim), flags=affine_flags) + return image + +def clear_boundbox(image): + """Clear the blank surrounding area of an image. + + It improves classifier performance. + The image must be a cv2 image. + + """ + top = 0 + bot = image.shape[0] + right = image.shape[1] + left = 0 + it = 0 + for index, row in enumerate(image): + if (not np.all(row==0)) and it == 0: + if not np.all(image[index + 1] == 0): + top = index + it = 1 + elif np.all(row == 0) and it == 1: + bot = index + break + it = 0 + for index, col in enumerate(image.T): + if (not np.all(col == 0)) and it == 0: + if index + 2 >= right or not np.all(image.T[index + 2] == 0): + left = index + it = 1 + elif np.all(col == 0) and it == 1: + right = index + break + cleared_image = image[top:bot, left:right] + return cleared_image diff --git a/eyegrade/ocr/sample.py b/eyegrade/ocr/sample.py new file mode 100644 index 00000000..0a392e43 --- /dev/null +++ b/eyegrade/ocr/sample.py @@ -0,0 +1,240 @@ +# Eyegrade: grading multiple choice questions with a webcam +# Copyright (C) 2015 Jesus Arias Fisteus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see +# . +# +from __future__ import division + +import os.path +import collections +import random + +import cv2 +import numpy as np + +from .. import geometry as g + + +class Sample(object): + def __init__(self, corners, + image=None, image_filename=None, label=None): + if image is None and not image_filename: + raise ValueError('Either image of image_filename are needed') + if corners.shape != (4, 2): + raise ValueError('Corners must be a 4x2 matrix') + self.corners = corners + self.image_filename = image_filename + self.label = label + self._image = image + self._features = None + + @property + def image(self): + if self._image is None: + self._image = cv2.imread(self.image_filename, 0) + if self._image is None: + raise ValueError('Cannot load image: {}'\ + .format(self.image_filename)) + return self._image + + def check_label(self, label): + return self.label == label + + def crop(self): + min_x = min(self.corners[:, 0]) + max_x = max(self.corners[:, 0]) + min_y = min(self.corners[:, 1]) + max_y = max(self.corners[:, 1]) + new_corners = self.corners - np.array([(min_x, min_y)] * 4) + new_image = self.image[min_y:max_y + 1, min_x:max_x + 1] + return Sample(new_corners, image=new_image) + + +class DigitSampleFromCam(Sample): + def __init__(self, corners, image): + corners = adjust_cell_corners(image, corners) + super(DigitSampleFromCam, self).__init__(corners, image=image) + + +class CrossSampleFromCam(Sample): + def __init__(self, corners, image): + corners = self._adjust_cell_corners(image, corners) + super(CrossSampleFromCam, self).__init__(corners, image=image) + + @staticmethod + def _adjust_cell_corners(image, corners): + plu, prd = g.closer_points_rel(corners[0, :], corners[3, :], 0.8) + pru, pld = g.closer_points_rel(corners[1, :], corners[2, :], 0.8) + return np.array((plu, pru, pld, prd)) + + +class SampleSet(object): + def __init__(self): + self.samples_dict = collections.defaultdict(list) + + def __len__(self): + return sum(len(self.samples_dict[label]) \ + for label in self.samples_dict) + + def __iter__(self): + return self.iterate_samples() + + @property + def distribution(self): + return [(label, len(self.samples_dict[label])) \ + for label in self.samples_dict] + + def load_from_loader(self, loader): + self.load_from_samples(loader.iterate_samples()) + + def load_from_samples(self, samples): + for sample in samples: + if sample.label is None: + raise ValueError('Unlabelled sample in SampleSet') + self.samples_dict[sample.label].append(sample) + + def load_from_sample_set(self, sample_set): + for sample in sample_set.iterate_samples(): + if sample.label is None: + raise ValueError('Unlabelled sample in SampleSet') + self.samples_dict[sample.label].append(sample) + + def load_from_sample_sets(self, sample_sets): + for sample_set in sample_sets: + self.load_from_sample_set(sample_set) + + def samples(self, oversampling=False, downsampling=False): + return [sample for sample \ + in self.iterate_samples(oversampling=oversampling, + downsampling=downsampling)] + + def iterate_samples(self, oversampling=False, downsampling=False): + if oversampling and downsampling: + raise ValueError('Over and donwsampling are mutually-exclusive') + if oversampling: + iterator = self._iterate_samples_oversampling() + elif downsampling: + iterator = self._iterate_samples_downsampling() + else: + iterator = self._iterate_samples() + return iterator + + def partition(self, num_groups): + total_samples = len(self) + partition_lens = [total_samples // num_groups] * num_groups + for i in range(total_samples % num_groups): + partition_lens[i] += 1 + partitions = [] + samples = set(self.samples()) + for partition_len in partition_lens: + partition = random.sample(samples, partition_len) + samples -= set(partition) + sample_set = SampleSet() + sample_set.load_from_samples(partition) + partitions.append(sample_set) + return partitions + + def oversample(self): + sample_set = SampleSet() + sample_set.load_from_samples(self.samples(oversampling=True)) + return sample_set + + def downsample(self): + sample_set = SampleSet() + sample_set.load_from_samples(self.samples(downsampling=True)) + return sample_set + + def _iterate_samples(self): + for samples in self.samples_dict.itervalues(): + for sample in samples: + yield sample + + def _iterate_samples_oversampling(self): + max_num = self._max_sample_num() + for samples in self.samples_dict.itervalues(): + rounds = max_num // len(samples) + remaining = max_num % len(samples) + for i in range(rounds): + for sample in samples: + yield sample + for sample in random.sample(samples, remaining): + yield sample + + def _iterate_samples_downsampling(self): + min_num = self._min_sample_num() + for samples in self.samples_dict.itervalues(): + for sample in random.sample(samples, min_num): + yield sample + + def _max_sample_num(self): + return max(len(self.samples_dict[label]) \ + for label in self.samples_dict) + + def _min_sample_num(self): + return min(len(self.samples_dict[label]) \ + for label in self.samples_dict) + + +class SampleLoader(object): + def __init__(self, filename): + self.filename = filename + self.dirname = os.path.dirname(filename) + + def samples(self): + return [sample for sample in self.iterate_samples()] + + def iterate_samples(self): + with open(self.filename, mode='r') as f: + for line in f: + if line.strip(): + yield self._parse_sample(line) + + def _parse_sample(self, line): + parts = [p.strip() for p in line.split('\t')] + if len(parts) != 10: + raise ValueError("Syntax error in samples file") + image_path = os.path.join(self.dirname, parts[0]) + label = int(parts[1]) + corners = np.zeros((4, 2), dtype=np.uint16) + corners[0,0] = int(parts[2]) # left top + corners[0,1] = int(parts[3]) + corners[1,0] = int(parts[4]) # right top + corners[1,1] = int(parts[5]) + corners[2,0] = int(parts[6]) # left bottom + corners[2,1] = int(parts[7]) + corners[3,0] = int(parts[8]) # right bottom + corners[3,1] = int(parts[9]) + return Sample(corners, image_filename=image_path, label=label) + + +def adjust_cell_corners(image, corners): + plu = adjust_cell_corner(image, corners[0, :], corners[3, :]) + prd = adjust_cell_corner(image, corners[3, :], corners[0, :]) + pru = adjust_cell_corner(image, corners[1, :], corners[2, :]) + pld = adjust_cell_corner(image, corners[2, :], corners[1, :]) + return np.array([plu, pru, pld, prd]) + +def adjust_cell_corner(image, corner, towards_corner): + margin = None + for x, y in g.walk_line_ordered(corner, towards_corner): + if margin is None: + if image[y, x] == 0: + margin = 2 + else: + margin -= 1 + if margin == 0: + return (x, y) + # In case of failure, return the original point + return corner diff --git a/eyegrade/qtgui/dialogs.py b/eyegrade/qtgui/dialogs.py index 53755380..348a2f13 100644 --- a/eyegrade/qtgui/dialogs.py +++ b/eyegrade/qtgui/dialogs.py @@ -275,7 +275,7 @@ class DialogCameraSelection(QDialog): def __init__(self, capture_context, parent): """Initializes the dialog. - `capture_context` is the imageproc.ExamCaptureContext object + `capture_context` is the detection.ExamCaptureContext object to be used. """ @@ -388,7 +388,7 @@ def _create_about_tab(self):


{1} {2}
- (c) 2010-2015 Jesús Arias Fisteus
+ (c) 2010-2017 Jesús Arias Fisteus and contributors
{3}
{4} @@ -432,8 +432,14 @@ def _create_developers_tab(self):

  • Jesús Arias Fisteus

{1}:

  • Jonathan Araneda Labarca
+

{2}:

+
  • Rodrigo Argüello
+

{3}:

+
  • Roberto González
""".format(_('Lead developers'), - _('Exam configuration dialogs')) + _('Exam configuration dialogs'), + _('Manuscript digits recognition'), + _('Testing and other contributions'),) label = QLabel(text) label.setTextInteractionFlags((Qt.LinksAccessibleByKeyboard | Qt.LinksAccessibleByMouse diff --git a/eyegrade/qtgui/gui.py b/eyegrade/qtgui/gui.py index 20cd0bc8..505ac4aa 100644 --- a/eyegrade/qtgui/gui.py +++ b/eyegrade/qtgui/gui.py @@ -27,7 +27,7 @@ QLabel, QIcon, QAction, QMenu, QDialog, QFileDialog, QHBoxLayout, QMessageBox, QPixmap, - QKeySequence, ) + QKeySequence, QStyleFactory, ) from PyQt4.QtCore import (Qt, QTimer, QRunnable, QThreadPool, QObject, pyqtSignal,) @@ -550,7 +550,8 @@ def update_status_bar(self, text): class Interface(object): - def __init__(self, app, id_enabled, id_list_enabled, argv): + def __init__(self, app, id_enabled, id_list_enabled, argv, + preferred_styles=None): self.app = app self.id_enabled = id_enabled self.id_list_enabled = id_list_enabled @@ -565,6 +566,7 @@ def __init__(self, app, id_enabled, id_list_enabled, argv): self.window.close) self.register_listener(('actions', 'help', 'about'), self.show_about_dialog) + self._configure_qt_style(preferred_styles) def run(self): return self.app.exec_() @@ -766,12 +768,12 @@ def dialog_open_session(self): FileNameFilters.session_db, None, QFileDialog.DontUseNativeDialog) - return str(filename) if filename else None + return unicode(filename) if filename else None def dialog_camera_selection(self, capture_context): """Displays a camera selection dialog. - `capture_context` is the imageproc.ExamCaptureContext object + `capture_context` is the detection.ExamCaptureContext object to be used. """ @@ -862,3 +864,11 @@ def run_worker(self, task, callback): def show_about_dialog(self): dialog = dialogs.DialogAbout(self.window) dialog.exec_() + + def _configure_qt_style(self, preferred_styles): + if preferred_styles is not None: + for style_key in preferred_styles: + style = QStyleFactory.create(style_key) + if style is not None: + self.app.setStyle(style) + break diff --git a/eyegrade/qtgui/widgets.py b/eyegrade/qtgui/widgets.py index de144ff4..1f675721 100644 --- a/eyegrade/qtgui/widgets.py +++ b/eyegrade/qtgui/widgets.py @@ -430,20 +430,20 @@ def paintEvent(self, event): else: painter.drawImage(event.rect(), self.image) - def display_capture(self, ipl_image): + def display_capture(self, cv_image): """Displays a captured image in the window. - The image is in the OpenCV IPL format. + The image is in the numpy format used by opencv. """ # It is important to use the variable data to prevent issue #58. - data = ipl_image.tostring() - self.image = QImage(data, ipl_image.width, ipl_image.height, + data = cv_image.data + height, width, nbytes = cv_image.shape + self.image = QImage(data, width, height, nbytes * width, QImage.Format_RGB888).rgbSwapped() if self.logo is not None: painter = QPainter(self.image) - painter.drawPixmap(ipl_image.width - 40, ipl_image.height - 40, - 36, 36, self.logo) + painter.drawPixmap(width - 40, height - 40, 36, 36, self.logo) self.update() def display_wait_image(self): diff --git a/eyegrade/qtgui/wizards.py b/eyegrade/qtgui/wizards.py index 24962698..8c00c3ae 100644 --- a/eyegrade/qtgui/wizards.py +++ b/eyegrade/qtgui/wizards.py @@ -881,4 +881,3 @@ def _check_student_ids_file(self, file_name): QMessageBox.critical(self, _('Error in student list'), file_name + '\n\n' + str(e)) return valid, '' - diff --git a/eyegrade/sessiondb.py b/eyegrade/sessiondb.py index 4edf330a..9e4ed8c3 100644 --- a/eyegrade/sessiondb.py +++ b/eyegrade/sessiondb.py @@ -22,6 +22,7 @@ from . import utils from . import capture +from . import images class SessionDB(object): @@ -493,7 +494,7 @@ def save_raw_capture(self, exam_id, capture, student): capture.save_image_raw(raw_name) def load_raw_capture(self, exam_id): - return capture.load_image(self.get_raw_capture_path(exam_id)) + return images.load_image(self.get_raw_capture_path(exam_id)) def get_raw_capture_path(self, exam_id): path = os.path.join(self.session_dir, 'internal', diff --git a/eyegrade/utils.py b/eyegrade/utils.py index afa5fabb..736a2b1f 100644 --- a/eyegrade/utils.py +++ b/eyegrade/utils.py @@ -16,7 +16,7 @@ # . # -from __future__ import unicode_literals +from __future__ import unicode_literals, print_function import ConfigParser import csv @@ -28,12 +28,13 @@ import re import io import fractions +import contextlib program_name = 'eyegrade' web_location = 'http://www.eyegrade.org/' source_location = 'https://github.com/jfisteus/eyegrade' help_location = 'http://www.eyegrade.org/doc/user-manual/' -version = '0.6.4' +version = '0.7' version_status = 'alpha' re_exp_email = r'^[a-zA-Z0-9._%-\+]+@[a-zA-Z0-9._%-]+.[a-zA-Z]{2,6}$' @@ -47,6 +48,30 @@ _default_capture_pattern = 'exam-{student-id}-{seq-number}.png' +# The data_dir variable will be intially none. The functions in this +# module that depend on the data directory will initialize it if +# needed. +data_dir = None + +def path_to_unicode(path): + """Convert filesystem paths from str to unicode.""" + encoding = sys.getfilesystemencoding() + if encoding is None: + # Some Unix systems might return None. Assume ASCII in that case: + encoding = 'ascii' + return unicode(path, encoding) + +def unicode_path_to_str(path): + """Convert filesystem paths from unicode to str.""" + encoding = sys.getfilesystemencoding() + if encoding is None: + # Some Unix systems might return None. Assume ASCII in that case: + encoding = 'ascii' + return path.encode(encoding) + +def user_home(): + return path_to_unicode(os.path.expanduser(b'~/')) + def _read_config(): """Reads the general config file and returns the resulting config object. @@ -54,14 +79,21 @@ def _read_config(): utils.config variable. """ - config = {'camera-dev': '0', - 'save-filename-pattern': _default_capture_pattern, - 'csv-dialect': 'tabs', - 'default-charset': 'utf8', # special value: 'system-default' - } + config = { + 'camera-dev': '0', + 'save-filename-pattern': _default_capture_pattern, + 'csv-dialect': 'tabs', + 'default-charset': 'utf8', # special value: 'system-default' + } parser = ConfigParser.SafeConfigParser() - parser.read([os.path.expanduser('~/.eyegrade.cfg'), - os.path.expanduser('~/.camgrade.cfg')]) + home = user_home() + try: + parser.read([os.path.join(home, u'.eyegrade.cfg'), + os.path.join(home, u'.camgrade.cfg'), + resource_path('default.cfg'),]) + except EyegradeException: + parser.read([os.path.join(home, u'.eyegrade.cfg'), + os.path.join(home, u'.camgrade.cfg'),]) if 'default' in parser.sections(): for option in parser.options('default'): config[option] = parser.get('default', option) @@ -74,12 +106,13 @@ def _read_config(): config['camera-dev'] = int(config['camera-dev']) if config['default-charset'] == 'system-default': config['default-charset'] = locale.getpreferredencoding() + if 'gui-styles' in config: + config['gui-styles'] = tuple(v.strip() + for v in config['gui-styles'].split(',')) + else: + config['gui-styles'] = None return config -# The global configuration object: -config = _read_config() - - class EyegradeException(Exception): """An Eyegrade-specific exception. @@ -242,29 +275,44 @@ def __ne__(self, other): def guess_data_dir(): - path = os.path.split(os.path.realpath(__file__))[0] - if path.endswith('.zip'): - path = os.path.split(path)[0] - paths_to_try = [os.path.join(path, 'data'), - os.path.join(path, '..', 'data'), - os.path.join(path, '..', '..', 'data'), - os.path.join(path, '..', '..', '..', 'data')] + u_file = path_to_unicode(__file__) + path = os.path.split(os.path.realpath(u_file))[0] + # An alternative path to try for pyinstaller's packages: + path_alt = os.path.split(path)[0] + paths_to_try = [ + os.path.join(path, 'data'), + os.path.join(path, '..', 'data'), + os.path.join(path, '..', '..', 'data'), + os.path.join(path, '..', '..', '..', 'data'), + os.path.join(path_alt, 'data'), + ] for p in paths_to_try: if os.path.isdir(p): return os.path.abspath(p) - raise Exception('Data path not found!') + raise EyegradeException('Data path not found!') -data_dir = guess_data_dir() +def init_data_dir(): + global data_dir + data_dir = guess_data_dir() def locale_dir(): + if data_dir is None: + init_data_dir() return os.path.join(data_dir, 'locale') def qt_translations_dir(): + if data_dir is None: + init_data_dir() return os.path.join(data_dir, 'qt-translations') def resource_path(file_name): + if data_dir is None: + init_data_dir() return os.path.join(data_dir, file_name) +# The global configuration object: +config = _read_config() + def read_results(filename, permutations = {}, allow_question_mark=False): """Parses an eyegrade results file. @@ -587,7 +635,7 @@ def encode_model(model, num_tables, num_answers): raise Exception('Model is currently limited to A - H') model_num = ord(model) - 65 num_bits = num_tables * num_answers - if model_num >= 2 ** (num_bits - 1): + if model_num >= 2 ** num_bits: raise Exception('Model number too big given the number of answers') seed = _int_to_bin(model_num, 3, True) seed[2] = not seed[2] @@ -607,7 +655,7 @@ def decode_model(bit_list, accept_model_0=False): """ # x3 = x0 ^ x1 ^ not x2; x0-x3 == x4-x7 == x8-x11 == ... valid = False - if len(bit_list) == 3: + if len(bit_list) == 2 or len(bit_list) == 3: valid = True elif len(bit_list) >= 4: if (bit_list[3] == bit_list[0] ^ bit_list[1] ^ (not bit_list[2])): @@ -1619,3 +1667,12 @@ def capture_name(filename_pattern, exam_id, student): def encode_string(text): return text.encode(config['default-charset']) + +@contextlib.contextmanager +def change_dir(directory): + prev_directory = os.getcwd() + if directory: + os.chdir(directory) + yield + if directory: + os.chdir(prev_directory) diff --git a/installers/linux/README b/installers/linux/README new file mode 100644 index 00000000..97de216e --- /dev/null +++ b/installers/linux/README @@ -0,0 +1,30 @@ +In order to create a single-file Linux executable file: + +1.- Install pyinstaller with pip (note that version 3.2 didn't work +for me because of a bug. I had to install version 3.1 instead). + +2.- Check that six and packaging are also installed. The should have +been installed with pyinstaller automatically, but I've seen the +problem that they dind't get installed. Install them with pip if +necessary. + +3.- From the main directory of the eyegrade source distribution, run: + +pyinstaller installers/pyinstaller/eyegrade.spec +pyinstaller installers/pyinstaller/eyegrade-create.spec + +The executable files eyegrade and eyegrade-create will appear inside +the "build" directory. + +Note that according to: + +https://pythonhosted.org/PyInstaller/usage.html#making-linux-apps-forward-compatible + +your executable files won't work in environments in which the +installed version of libc is older than the version you have in the +environment in which you run pyinstaller to create the executable +file. + +In addition, if you build the executable file on a 64-bit OS, it will +run only on 64-bit OSs. If you build it on a 32-bin OS, it will run +only on 32-bit OSs. \ No newline at end of file diff --git a/installers/pyinstaller/default.cfg b/installers/pyinstaller/default.cfg new file mode 100644 index 00000000..bfe70c4e --- /dev/null +++ b/installers/pyinstaller/default.cfg @@ -0,0 +1,2 @@ +[default] +gui-styles: Oxygen, Cleanlooks, Plastique diff --git a/installers/pyinstaller/eyegrade-create.spec b/installers/pyinstaller/eyegrade-create.spec new file mode 100644 index 00000000..78bf6d45 --- /dev/null +++ b/installers/pyinstaller/eyegrade-create.spec @@ -0,0 +1,19 @@ +# -*- mode: python -*- +a = Analysis(['../../bin/eyegrade-create'], + pathex=['.'], + hiddenimports=['six', 'packaging', 'packaging.version', + 'packaging.specifiers', 'packaging.requirements'], + hookspath=None, + runtime_hooks=None) +pyz = PYZ(a.pure) +exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + exclude_binaries=False, + name='eyegrade-create', + debug=False, + strip=None, + upx=True, + console=True) diff --git a/installers/pyinstaller/eyegrade.spec b/installers/pyinstaller/eyegrade.spec new file mode 100644 index 00000000..5baacc2b --- /dev/null +++ b/installers/pyinstaller/eyegrade.spec @@ -0,0 +1,26 @@ +# -*- mode: python -*- +import sys + +a = Analysis(['../../bin/eyegrade'], + pathex=['.'], + hiddenimports=['six', 'packaging', 'packaging.version', + 'packaging.specifiers', 'packaging.requirements'], + hookspath=None, + runtime_hooks=None) +a.datas += Tree('eyegrade/data', prefix='data') +a.datas += [('data/default.cfg', 'installers/pyinstaller/default.cfg', 'DATA')] +if sys.platform.startswith("win32"): + a.datas = list({tuple(map(str.upper, t)) for t in a.datas}) +pyz = PYZ(a.pure) +exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + exclude_binaries=False, + name='eyegrade', + debug=False, + strip=None, + upx=True, + console=False, + icon='eyegrade/data/eyegrade.ico') diff --git a/installers/windows/README.TXT b/installers/windows/README.TXT new file mode 100644 index 00000000..3c825593 --- /dev/null +++ b/installers/windows/README.TXT @@ -0,0 +1,21 @@ +This directory contains the files needed to build a Windows installer. + +You need to install pywin32 from its website, pyinstaller through pip, +and NSIS version 2.X. + +Use a 32-bit version of python 2.7 for maximum portability of the produced files. + +Create the files from the main eyegrade directory with: + +python installers\windows\build.py + +The output installer is created in the dist directory your main eyegrade directory. + + +Signing the installer +----------------------- + +Assuming you have a code signing key in a file named EyegradeKey.pfx, +sign the installer with the command: + +signtool sign /fd SHA512 /a /f EyegradeKey.pfx /p key-password /tr http://tsa.safecreative.org eyegrade-setup-XXX.exe diff --git a/installers/windows/build.py b/installers/windows/build.py new file mode 100644 index 00000000..af6d86f4 --- /dev/null +++ b/installers/windows/build.py @@ -0,0 +1,144 @@ +from __future__ import print_function + +import os +import os.path +import shutil +import sys +import subprocess +import traceback + +if not sys.platform.startswith("win32"): + print("This script can only run on a Windows platform") + sys.exit(1) + +eyegrade_dir = os.path.join(os.path.dirname( \ + os.path.dirname(os.path.dirname( \ + os.path.realpath(__file__))))) +build_dir = os.path.join(eyegrade_dir, 'build') +dist_dir = os.path.join(eyegrade_dir, 'dist') +dist_files_dir = os.path.join(dist_dir, 'eyegrade') +python_dir = os.path.join(os.path.dirname(sys.executable)) +pyi_spec_files = [ + os.path.join(eyegrade_dir, 'installers', 'pyinstaller', 'eyegrade.spec'), + os.path.join(eyegrade_dir, 'installers', 'pyinstaller', + 'eyegrade-create.spec'), +] +nsis_file = os.path.join(eyegrade_dir, 'installers', 'windows', 'eyegrade.nsi') + +def create_dir(dirname): + if os.path.exists(dirname): + if not os.path.isdir(dirname): + raise ValueError('{} should be a directory'.format(dirname)) + else: + os.makedirs(dirname) + +def move_files(filenames, dest_dir): + for filename in expected_files: + dest_full_name = os.path.join(dest_dir, os.path.split(filename)[1]) + print(dest_dir) + print(dest_full_name) + if os.path.exists(dest_full_name): + os.remove(dest_full_name) + shutil.move(filename, dest_dir) + +def is_exe(fpath): + return os.path.isfile(fpath) and os.access(fpath, os.X_OK) + +def which(program): + for path in os.environ['PATH'].split(os.pathsep): + path = path.strip('"') + exe_file = os.path.join(path, program) + if is_exe(exe_file): + return exe_file + return None + +def get_pyi_path(): + path = which('pyinstaller.exe') + if not path: + # Try in the current Python's installation + path = os.path.join(python_dir, 'Scripts', 'pyinstaller.exe') + if not is_exe(path): + path = None + return path + +def get_nsis_path(): + path = which('makensis.exe') + if not path: + # Try in the current Python's installation + path = os.path.join('C:\\', 'Program Files (x86)', 'NSIS', + 'makensis.exe') + if not is_exe(path): + path = os.path.join('C:\\', 'Program Files', 'NSIS', + 'makensis.exe') + if not is_exe(path): + path = None + return path + +def build_install_file_list(): + with open(os.path.join(build_dir, 'install_files.nsh'), 'w') as f: + for root, dirs, files in os.walk(dist_files_dir): + root_rel_path = os.path.relpath(root, dist_files_dir) + if root_rel_path == '.': + for file in files: + f.write('File "${{SOURCE_DIR}}\{}"\n'.format(file)) + else: + f.write('\n') + f.write('CreateDirectory "$INSTDIR\{}"\n'.format(root_rel_path)) + for file in files: + rel_path = os.path.join(root_rel_path, file) + f.write('File "/oname={}" "${{SOURCE_DIR}}\{}"\n'.format(rel_path, rel_path)) + +def build_uninstall_file_list(): + with open(os.path.join(build_dir, 'uninstall_files.nsh'), 'w') as f: + for root, dirs, files in os.walk(dist_files_dir, topdown=False): + root_rel_path = os.path.relpath(root, dist_files_dir) + f.write('\n') + if root_rel_path == '.': + for file in files: + f.write('Delete "$INSTDIR\{}"\n'.format(file)) + f.write('RMDir "$INSTDIR"') + else: + for file in files: + rel_path = os.path.join(root_rel_path, file) + f.write('Delete "$INSTDIR\{}"\n'.format(rel_path)) + f.write('RMDir "$INSTDIR\{}"\n'.format(root_rel_path)) + +create_dir(build_dir) +prev_cwd = os.getcwd() +os.chdir(eyegrade_dir) + +pyi_path = get_pyi_path() +if not pyi_path: + print('Error: PyInstaller executable pyinstaller.exe not found.') + sys.exit(1) +for filename in pyi_spec_files: + result = subprocess.call([pyi_path, filename]) + if result != 0: + print('Error: PyInstaller build failed for {}'.format(filename)) + sys.exit(1) + +expected_files = [ + os.path.join(dist_dir, "eyegrade.exe"), + os.path.join(dist_dir, "eyegrade-create.exe"), +] +try: + create_dir(dist_files_dir) + move_files(expected_files, dist_files_dir) +except: + print('Some executable files could not be moved') + traceback.print_exc() + sys.exit(1); + +build_install_file_list() +build_uninstall_file_list() + +nsis_path = get_nsis_path() +if not nsis_path: + print('Error: NSIS executable makensis.exe not found.') + sys.exit(1) +result = subprocess.call([nsis_path, nsis_file]) +if result != 0: + print('Error: NSIS build failed.') + sys.exit(1) + +os.chdir(prev_cwd) diff --git a/installers/windows/eyegrade.nsi b/installers/windows/eyegrade.nsi new file mode 100644 index 00000000..c92e6be4 --- /dev/null +++ b/installers/windows/eyegrade.nsi @@ -0,0 +1,216 @@ +;NSIS Eyegrade script + +;-------------------------------- +;Includes + + ; Modern UI + !include "MUI2.nsh" + + ; For computing installation size + !include "FileFunc.nsh" + +;-------------------------------- +;General + !define VERSION "0.7" + !define EYEGRADE_DIR "..\.." + + ;Name and file + Name "Eyegrade ${VERSION}" + OutFile "${EYEGRADE_DIR}\dist\eyegrade-setup-${VERSION}.exe" + + ;Default installation folder + InstallDir "$LOCALAPPDATA\Eyegrade" + + ;Get installation folder from registry if available + InstallDirRegKey HKCU "Software\Eyegrade" "" + + ;Request application privileges for Windows Vista + RequestExecutionLevel user + +;-------------------------------- +;Variables + + Var StartMenuFolder + +;-------------------------------- +;Interface Settings + + !define MUI_ABORTWARNING + +;-------------------------------- +;Pages + + !insertmacro MUI_PAGE_LICENSE "${EYEGRADE_DIR}\COPYING.TXT" + ;!insertmacro MUI_PAGE_COMPONENTS + !insertmacro MUI_PAGE_DIRECTORY + + ;Start Menu Folder Page Configuration + !define MUI_STARTMENUPAGE_REGISTRY_ROOT "HKCU" + !define MUI_STARTMENUPAGE_REGISTRY_KEY "Software\Eyegrade" + !define MUI_STARTMENUPAGE_REGISTRY_VALUENAME "Start Menu Folder" + + !insertmacro MUI_PAGE_STARTMENU Application $StartMenuFolder + + !insertmacro MUI_PAGE_INSTFILES + !define MUI_FINISHPAGE_NOAUTOCLOSE + !define MUI_FINISHPAGE_RUN + !define MUI_FINISHPAGE_RUN_NOTCHECKED + !define MUI_FINISHPAGE_RUN_TEXT "$(MsgRunEyegrade)" + !define MUI_FINISHPAGE_RUN_FUNCTION "LaunchApplication" + !insertmacro MUI_PAGE_FINISH + + !insertmacro MUI_UNPAGE_CONFIRM + !insertmacro MUI_UNPAGE_INSTFILES + +;-------------------------------- +;Languages + + !insertmacro MUI_LANGUAGE "English" + !insertmacro MUI_LANGUAGE "Spanish" + !insertmacro MUI_LANGUAGE "Galician" + + LangString MsgEyegradeRunning ${LANG_ENGLISH} \ + "Eyegrade is running. Please, close it and try again. If you have any file explorer window in a directory called Eyegrade, close it too" + LangString MsgEyegradeRunning ${LANG_SPANISH} \ + "Eyegrade se est� ejecutando. Por favor, ci�rrelo y vu�lvalo a intentar. Si tiene alg�n explorador de ficheros en una carpeta llamada Eyegrade, ci�rrelo tambi�n" + LangString MsgEyegradeRunning ${LANG_GALICIAN} \ + "Eyegrade estase a executar. Por favor, c�rreo e volva intentalo. Se ten algun explorador de ficheiros aberto nunha carpeta chamada Eyegrade, c�rrea tam�n" + LangString MsgRunEyegrade ${LANG_ENGLISH} \ + "Run Eyegrade when finished" + LangString MsgRunEyegrade ${LANG_SPANISH} \ + "Ejecutar Eyegrade al finalizar" + LangString MsgRunEyegrade ${LANG_GALICIAN} \ + "Executar Eyegrade � rematar" + +;-------------------------------- +;Installer Sections + +Section "" UninstallPreviousVersion +; MessageBox MB_OK "check for previous version" + ReadRegStr $R0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Eyegrade" \ + "QuietUninstallString" + StrCmp $R0 "" done + ExecWait '$R0' +done: + +SectionEnd + + +Section "Installing Eyegrade Files" InstEyegradeFiles + + SetOutPath "$INSTDIR" + + !define SOURCE_DIR "${EYEGRADE_DIR}\dist\eyegrade" + + !include "${EYEGRADE_DIR}\build\install_files.nsh" + + File "${EYEGRADE_DIR}\eyegrade\data\eyegrade.ico" + File "${EYEGRADE_DIR}\AUTHORS.TXT" + File "${EYEGRADE_DIR}\COPYING.TXT" + File "/oname=README.TXT" "${EYEGRADE_DIR}\README" + File "/oname=CHANGELOG.TXT" "${EYEGRADE_DIR}\Changelog" + CreateDirectory "$INSTDIR\examples" + SetOutPath "$INSTDIR\examples" + File "${EYEGRADE_DIR}\doc\sample-files\*.*" + File "/oname=example-word.doc" "${EYEGRADE_DIR}\doc\sample-files\ms-word\sample-exam.doc" + + ;Store installation folder + WriteRegStr HKCU "Software\Eyegrade" "" $INSTDIR + WriteRegStr HKCU "Software\Eyegrade" "Version" "${VERSION}" + + ;Create uninstaller + WriteUninstaller "$INSTDIR\Uninstall.exe" + + !insertmacro MUI_STARTMENU_WRITE_BEGIN Application + + ;Create shortcuts + CreateDirectory "$SMPROGRAMS\$StartMenuFolder" + CreateShortcut "$SMPROGRAMS\$StartMenuFolder\Eyegrade.lnk" "$INSTDIR\eyegrade.exe" + CreateShortcut "$SMPROGRAMS\$StartMenuFolder\Examples.lnk" "$INSTDIR\examples" + CreateShortcut "$SMPROGRAMS\$StartMenuFolder\Installation Folder.lnk" "$INSTDIR\" + CreateShortcut "$SMPROGRAMS\$StartMenuFolder\Uninstall.lnk" "$INSTDIR\Uninstall.exe" + + !insertmacro MUI_STARTMENU_WRITE_END + + ; Information for the uninstall menu + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Eyegrade" \ + "DisplayName" "Eyegrade" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Eyegrade" \ + "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Eyegrade" \ + "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Eyegrade" \ + "DisplayIcon" "$\"$INSTDIR\eyegrade.ico$\"" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Eyegrade" \ + "DisplayVersion" "${VERSION}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Eyegrade" \ + "Publisher" "Jesus Arias Fisteus" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Eyegrade" \ + "URLInfoAbout" "http://www.eyegrade.org/" + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Eyegrade" \ + "NoModify" "1" + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Eyegrade" \ + "EstimatedSize" "$0" + +SectionEnd + +;-------------------------------- +;Descriptions + + ;Language strings +; LangString DESC_InstEyegradeFiles ${LANG_ENGLISH} "Eyegrade executable files." + + ;Assign language strings to sections + ; !insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN + ; !insertmacro MUI_DESCRIPTION_TEXT ${InstEyegradeFiles} $(DESC_InstEyegradeFiles) + ; !insertmacro MUI_FUNCTION_DESCRIPTION_END + +;-------------------------------- +;Uninstaller Section + +Section "Uninstall" + + Delete "$INSTDIR\eyegrade.ico" + Delete "$INSTDIR\AUTHORS.TXT" + Delete "$INSTDIR\COPYING.TXT" + Delete "$INSTDIR\README.TXT" + Delete "$INSTDIR\CHANGELOG.TXT" + Delete "$INSTDIR\uninstall.exe" + RMDir /r "$INSTDIR\examples" + + !include "${EYEGRADE_DIR}\build\uninstall_files.nsh" + + !insertmacro MUI_STARTMENU_GETFOLDER Application $StartMenuFolder + + Delete "$SMPROGRAMS\$StartMenuFolder\Eyegrade.lnk" + Delete "$SMPROGRAMS\$StartMenuFolder\Uninstall.lnk" + Delete "$SMPROGRAMS\$StartMenuFolder\Examples.lnk" + Delete "$SMPROGRAMS\$StartMenuFolder\Installation Folder.lnk" + RMDir "$SMPROGRAMS\$StartMenuFolder" + + DeleteRegKey HKCU "Software\Eyegrade\Version" + DeleteRegKey /ifempty HKCU "Software\Eyegrade" + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Eyegrade" +SectionEnd + +Function LaunchApplication + ExecShell "" "$INSTDIR\Eyegrade.exe" +FunctionEnd + +Function .onInit + FindWindow $0 "" "Eyegrade" + StrCmp $0 0 notRunning + MessageBox MB_OK|MB_ICONEXCLAMATION "$(MsgEyegradeRunning)" /SD IDOK + Abort +notRunning: +FunctionEnd + +Function un.onInit + FindWindow $0 "" "Eyegrade" + StrCmp $0 0 notRunning + MessageBox MB_OK|MB_ICONEXCLAMATION "$(MsgEyegradeRunning)" /SD IDOK + Abort +notRunning: +FunctionEnd diff --git a/setup.py b/setup.py index 7d684ffc..5b25da9e 100644 --- a/setup.py +++ b/setup.py @@ -1,13 +1,96 @@ #!/usr/bin/env python -from setuptools import setup +import os +import sys +import setuptools -setup(name='eyegrade', - version='0.6.4', - description='Grading multiple choice questions with a webcam', +def read(fname): + return open(os.path.join(os.path.dirname(__file__), fname)).read() + +if sys.version_info[0] != 2 or sys.version_info[1] not in [7]: + print('ztreamy needs Python 2.7') + sys.exit(1) + +long_description = """ +Eyegrade +(``_) +uses a webcam to grade multiple choice question exams. +Needing just a cheap low-end webcam, it aims to be a low-cost +and portable alternative to other solutions based on scanners. + +The main features of Eyegrade are: + +- Grading the exams: By using a webcam, the graphical user interface + of Eyegrade allows you to grade your exams. Eyegrade is able to + recognize not only the answers to the questions, but also the + identity of the student by using its hand-written digit recognition + module. The whole process is supervised by the user in order to + detect and fix potential detection errors. + +- Exporting grades: Grades can be exported in CSV format, compatible + with other programs such as spreadsheets. + +- Typesetting the exams: Although you can create your exams with other + tools, Eyegrade integrates an utility to creating MCQ exams. It is + able to create your exams in PDF format. Eyegrade can automatically + build several versions of the exam by shuffling questions and the + choices within the questions. + +The user manual can be found at +``_ + +Requirements: +-------------- + +Eyegrade runs on Python 2.7 only. +In addition, it requires `OpenCV version 2.4 `_ +and `PyQt4 `_ +to work properly: + +- For GNU/Linux systems install those packages from your distribution. + For example, in Debian (Stretch and previous versions) and Ubuntu + (16.10 and previous versions) just install the packages + `python-opencv` and `python-qt4`. + I've tested Eyegrade with the OpenCV 2.4 series. + Some Linux distributions ship OpenCV 3. + Note that API changes in that version prevent it from + working with this version of Eyegrade. + I'll try to adapt the code as soon as possible. + +- For Windows platforms you can download OpenCV and PyQt4 from their + official websites. + +- For Mac OS/X I haven't tested the program. + I believe it should be possible to install these dependencies + and make Eyegrade work, + but I'm not sure because I don't own a Mac computer. + Feedback on this would be much appreciated. +""" + +setuptools.setup(name='eyegrade', + version='0.7', + description='Grade MCQ exams with a webcam', + long_description=long_description, author='Jesus Arias Fisteus', author_email='jfisteus@gmail.com', url='http://www.eyegrade.org/', - packages=['eyegrade', 'eyegrade.qtgui'], - package_data={'eyegrade': ['data/*']}, - ) + packages=['eyegrade', 'eyegrade.qtgui', 'eyegrade.ocr'], + package_data={'eyegrade': ['data/*', 'data/svm/*']}, + scripts=['bin/eyegrade', 'bin/eyegrade-create'], + test_suite ="tests.get_tests", + classifiers= [ + 'Development Status :: 4 - Beta', + 'Environment :: X11 Applications :: Qt', + 'Intended Audience :: Education', + 'License :: OSI Approved :: ' + 'GNU Lesser General Public License v3 or later (LGPLv3+)', + 'Natural Language :: Spanish', + 'Natural Language :: English', + 'Natural Language :: Galician', + 'Operating System :: POSIX :: Linux', + 'Operating System :: Microsoft :: Windows', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Topic :: Education', + ], +) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29b..9978b3b5 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,11 @@ +import unittest +import os.path + +def load_tests(loader, standard_tests, pattern): + this_dir = os.path.dirname(__file__) + package_tests = loader.discover(start_dir=this_dir, pattern=pattern) + standard_tests.addTests(package_tests) + return standard_tests + +def get_tests(): + return load_tests(unittest.TestLoader(), unittest.TestSuite(), 'test*.py')