├── .clang-tidy ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ └── check.yml ├── .gitignore ├── LICENSE ├── README.md ├── howdy-gtk ├── bin │ └── howdy-gtk.in ├── debian │ ├── changelog │ ├── compat │ ├── control │ ├── copyright │ ├── howdy-gtk.links │ ├── howdy-gtk.lintian-overrides │ ├── install │ ├── postinst │ ├── rules │ └── source │ │ ├── format │ │ └── options ├── meson.build └── src │ ├── authsticky.py │ ├── i18n.py │ ├── init.py │ ├── logo.png │ ├── logo_about.png │ ├── main.glade │ ├── onboarding.glade │ ├── onboarding.py │ ├── paths.py.in │ ├── paths_factory.py │ ├── polkit │ └── com.github.boltgolt.howdy-gtk.policy.in │ ├── tab_models.py │ ├── tab_video.py │ └── window.py ├── howdy ├── archlinux │ ├── .gitignore │ └── howdy │ │ ├── .gitignore │ │ └── PKGBUILD ├── debian │ ├── changelog │ ├── compat │ ├── control │ ├── copyright │ ├── howdy.lintian-overrides │ ├── howdy.manpages │ ├── install │ ├── postinst │ ├── preinst │ ├── prerm │ ├── rules │ └── source │ │ ├── format │ │ └── options ├── howdy.1 ├── meson.build └── src │ ├── autocomplete │ └── howdy.in │ ├── bin │ └── howdy.in │ ├── cli.py │ ├── cli │ ├── __init__.py │ ├── add.py │ ├── clear.py │ ├── config.py │ ├── disable.py │ ├── list.py │ ├── remove.py │ ├── set.py │ ├── snap.py │ └── test.py │ ├── compare.py │ ├── config.ini │ ├── dlib-data │ ├── .gitignore │ ├── Readme.md │ └── install.sh │ ├── i18n.py │ ├── logo.png │ ├── meson.build │ ├── pam-config │ └── howdy.in │ ├── pam │ ├── .clang-tidy │ ├── .gitignore │ ├── README.md │ ├── enter_device.cc │ ├── enter_device.hh │ ├── main.cc │ ├── main.hh │ ├── meson.build │ ├── optional_task.hh │ ├── paths.hh.in │ ├── po │ │ ├── LINGUAS │ │ ├── POTFILES │ │ └── meson.build │ └── subprojects │ │ └── inih.wrap │ ├── paths.py.in │ ├── paths_factory.py │ ├── recorders │ ├── __init__.py │ ├── ffmpeg_reader.py │ ├── pyv4l2_reader.py │ ├── v4l2.py │ └── video_capture.py │ ├── rubberstamps │ ├── __init__.py │ ├── hotkey.py │ └── nod.py │ └── snapshot.py ├── meson.build ├── meson.options └── meson_options.txt /.clang-tidy: -------------------------------------------------------------------------------- 1 | Checks: 'clang-diagnostic-*,clang-analyser-*,-clang-diagnostic-unused-command-line-argument,google-*,bugprone-*,modernize-*,performance-*,portability-*,readability-*,-bugprone-easily-swappable-*,-readability-magic-numbers,-google-readability-todo' 2 | WarningsAsErrors: 'clang-diagnostic-*,clang-analyser-*,-clang-diagnostic-unused-command-line-argument,google-*,bugprone-*,modernize-*,performance-*,portability-*,readability-*,-bugprone-easily-swappable-*,-readability-magic-numbers,-google-readability-todo' 3 | CheckOptions: 4 | - key: readability-function-cognitive-complexity.Threshold 5 | value: '50' 6 | 7 | # vim:syntax=yaml 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | custom: https://www.buymeacoffee.com/boltgolt 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report something that's not working 4 | 5 | --- 6 | 7 | _Please describe the issue in as much detail as possible, including any errors and traces._ 8 | _If your issue is a camera issue, be sure to also post the image generated by running `sudo howdy snapshot`._ 9 | 10 | 11 | 12 | 13 | ---- 14 | 15 | I've searched for similar issues already, and my issue has not been reported yet. 16 | 17 | Linux distribution (if applicable): 18 | 19 | Howdy version (`sudo howdy version`): 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: 'Suggest a feature or improvement ' 4 | 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | _Please make sure to target the "dev" branch if it exists_ 2 | _REMOVE THIS MESSAGE IN THE PULL REQUEST_ 3 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: check 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Install required libraries 9 | run: > 10 | sudo apt-get update && sudo apt-get install -y 11 | python3 python3-pip python3-setuptools python3-wheel 12 | cmake make build-essential clang-tidy 13 | libpam0g-dev libinih-dev libevdev-dev 14 | python3-dev libopencv-dev 15 | 16 | - name: Install meson 17 | run: sudo python3 -m pip install meson ninja 18 | 19 | - uses: actions/checkout@v2 20 | 21 | - name: Build 22 | run: | 23 | meson setup build 24 | ninja -C build 25 | 26 | - name: Check source code 27 | run: | 28 | ninja clang-tidy -C build 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # generated models 104 | /howdy/src/models 105 | 106 | # snapshots 107 | /howdy/src/snapshots 108 | 109 | # build files 110 | debian/howdy.substvars 111 | debian/files 112 | debian/debhelper-build-stamp 113 | debian/howdy 114 | 115 | # vscode 116 | .vscode/* 117 | !.vscode/settings.json 118 | !.vscode/tasks.json 119 | !.vscode/launch.json 120 | !.vscode/extensions.json 121 | !.vscode/*.code-snippets 122 | 123 | # Local History for Visual Studio Code 124 | .history/ 125 | 126 | # Built Visual Studio Code Extensions 127 | *.vsix 128 | 129 | # Meson 130 | subprojects/ 131 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 boltgolt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://boltgolt.nl/howdy/banner.png) 2 | 3 |

4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |

20 | 21 | Howdy provides Windows Hello™ style authentication for Linux. Use your built-in IR emitters and camera in combination with facial recognition to prove who you are. 22 | 23 | Using the central authentication system (PAM), this works everywhere you would otherwise need your password: Login, lock screen, sudo, su, etc. 24 | 25 | ## Installation 26 | 27 | Howdy is currently available and packaged for Debian/Ubuntu, Arch Linux, Fedora and openSUSE. If you’re interested in packaging Howdy for your distro, don’t hesitate to open an issue. 28 | 29 | **Note:** The build of dlib can hang on 100% for over a minute, give it time. 30 | 31 | ### Ubuntu or Linux Mint 32 | 33 | Run the installer by pasting (`ctrl+shift+V`) the following commands into the terminal one at a time: 34 | 35 | ``` 36 | sudo add-apt-repository ppa:boltgolt/howdy 37 | sudo apt update 38 | sudo apt install howdy 39 | ``` 40 | 41 | This will guide you through the installation. 42 | 43 | ### Debian 44 | 45 | Download the .deb file from the [Releases page](https://github.com/boltgolt/howdy/releases) and install with gdebi. 46 | 47 | ### Arch Linux 48 | 49 | _Maintainer wanted._ 50 | 51 | Install the `howdy` package from the AUR. For AUR installation instructions, take a look at this [wiki page](https://wiki.archlinux.org/index.php/Arch_User_Repository#Installing_packages). 52 | 53 | You will need to do some additional configuration steps. Please read the [ArchWiki entry](https://wiki.archlinux.org/index.php/Howdy) for more information. 54 | 55 | ### Fedora 56 | 57 | _Maintainer: [@luyatshimbalanga](https://github.com/luyatshimbalanga)_ 58 | 59 | The `howdy` package is available as a [Fedora COPR repository](https://copr.fedorainfracloud.org/coprs/principis/howdy/), install it by simply executing the following commands in a terminal: 60 | 61 | ``` 62 | sudo dnf copr enable principis/howdy 63 | sudo dnf --refresh install howdy 64 | ``` 65 | 66 | *Note:* Fedora 41 [removed support for Python2](https://fedoraproject.org/wiki/Changes/RetirePython2.7), but at this point in time Howdy still depends on it. If the install fails, you can fix this by installing the beta Repository and removing the release version: 67 | 68 | ``` 69 | sudo dnf copr remove principis/howdy 70 | sudo dnf copr enable principis/howdy-beta 71 | sudo dnf --refresh install howdy 72 | ``` 73 | 74 | See the link to the COPR repository for detailed configuration steps. 75 | 76 | ### openSUSE 77 | 78 | _Maintainer: [@dmafanasyev](https://github.com/dmafanasyev)_ 79 | 80 | Go to the [openSUSE wiki page](https://en.opensuse.org/SDB:Facial_authentication) for detailed installation instructions. 81 | 82 | ### Building from source 83 | 84 | If you want to build Howdy from source, a few dependencies are required. 85 | 86 | #### Dependencies 87 | 88 | - Python 3.6 or higher 89 | * pip 90 | * setuptools 91 | * wheel 92 | - meson version 0.64 or higher 93 | - ninja 94 | - INIReader (can be pulled from git automatically if not found) 95 | - libevdev 96 | 97 | To install them on Debian/Ubuntu for example: 98 | 99 | ``` 100 | sudo apt-get update && sudo apt-get install -y \ 101 | python3 python3-pip python3-setuptools python3-wheel \ 102 | cmake make build-essential \ 103 | libpam0g-dev libinih-dev libevdev-dev python3-opencv \ 104 | python3-dev libopencv-dev 105 | ``` 106 | 107 | #### Build 108 | 109 | ```sh 110 | meson setup build 111 | meson compile -C build 112 | ``` 113 | 114 | You can also install Howdy to your system with `meson install -C build`. 115 | 116 | ## Setup 117 | 118 | After installation, Howdy needs to learn what you look like so it can recognise you later. Run `sudo howdy add` to add a face model. 119 | 120 | If nothing went wrong we should be able to run sudo by just showing your face. Open a new terminal and run `sudo -i` to see it in action. Please check [this wiki page](https://github.com/boltgolt/howdy/wiki/Common-issues) if you're experiencing problems or [search](https://github.com/boltgolt/howdy/issues) for similar issues. 121 | 122 | If you're curious you can run `sudo howdy config` to open the central config file and see the options Howdy has to offer. On most systems this will open the nano editor, where you have to press `ctrl`+`x` to save your changes. 123 | 124 | ## CLI 125 | 126 | The installer adds a `howdy` command to manage face models for the current user. Use `howdy --help` or `man howdy` to list the available options. 127 | 128 | Usage: 129 | ``` 130 | howdy [-U user] [-y] command [argument] 131 | ``` 132 | 133 | | Command | Description | 134 | |-----------|-----------------------------------------------| 135 | | `add` | Add a new face model for a user | 136 | | `clear` | Remove all face models for a user | 137 | | `config` | Open the config file in your default editor | 138 | | `disable` | Disable or enable howdy | 139 | | `list` | List all saved face models for a user | 140 | | `remove` | Remove a specific model for a user | 141 | | `snapshot`| Take a snapshot of your camera input | 142 | | `test` | Test the camera and recognition methods | 143 | | `version` | Print the current version number | 144 | 145 | ## Contributing [![](https://img.shields.io/travis/boltgolt/howdy/dev.svg?label=dev%20build)](https://github.com/boltgolt/howdy/tree/dev) [![](https://img.shields.io/github/issues-raw/boltgolt/howdy/enhancement.svg?label=feature+requests&colorB=4c1)](https://github.com/boltgolt/howdy/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement) 146 | 147 | The easiest ways to contribute to Howdy is by starring the repository and opening GitHub issues for features you'd like to see. If you want to do more, you can also [buy me a coffee](https://www.buymeacoffee.com/boltgolt). 148 | 149 | Code contributions are also very welcome. If you want to port Howdy to another distro, feel free to open an issue for that too. 150 | 151 | ## Troubleshooting 152 | 153 | Any Python errors get logged directly into the console and should indicate what went wrong. If authentication still fails but no errors are printed, you could take a look at the last lines in `/var/log/auth.log` to see if anything has been reported there. 154 | 155 | Please first check the [wiki on common issues](https://github.com/boltgolt/howdy/wiki/Common-issues) and 156 | if you encounter an error that hasn't been reported yet, don't be afraid to open a new issue. 157 | 158 | ## A note on security 159 | 160 | This package is in no way as secure as a password and will never be. Although it's harder to fool than normal face recognition, a person who looks similar to you, or a well-printed photo of you could be enough to do it. Howdy is a more quick and convenient way of logging in, not a more secure one. 161 | 162 | To minimize the chance of this program being compromised, it's recommended to leave Howdy in `/lib/security` and to keep it read-only. 163 | 164 | DO NOT USE HOWDY AS THE SOLE AUTHENTICATION METHOD FOR YOUR SYSTEM. 165 | -------------------------------------------------------------------------------- /howdy-gtk/bin/howdy-gtk.in: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | @python_path@ "@script_path@" "$@" -------------------------------------------------------------------------------- /howdy-gtk/debian/changelog: -------------------------------------------------------------------------------- 1 | howdy-gtk (0.0.1) xenial; urgency=medium 2 | 3 | * Initial testing release with sticky authentication ui 4 | 5 | -- boltgolt Thu, 03 Dec 2020 00:08:49 +0200 6 | -------------------------------------------------------------------------------- /howdy-gtk/debian/compat: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /howdy-gtk/debian/control: -------------------------------------------------------------------------------- 1 | Source: howdy-gtk 2 | Section: misc 3 | Priority: optional 4 | Standards-Version: 3.9.7 5 | Build-Depends: python, dh-python, devscripts, dh-make, debhelper, fakeroot 6 | Maintainer: boltgolt 7 | Vcs-Git: https://github.com/boltgolt/howdy 8 | 9 | Package: howdy-gtk 10 | Homepage: https://github.com/boltgolt/howdy 11 | Architecture: all 12 | Depends: ${misc:Depends}, curl|wget, python3, python3-pip, python3-dev, python-gtk2, python-gtk2-dev, cmake 13 | Description: Optional UI package for Howdy, written in Gtk 14 | -------------------------------------------------------------------------------- /howdy-gtk/debian/copyright: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 boltgolt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /howdy-gtk/debian/howdy-gtk.links: -------------------------------------------------------------------------------- 1 | /usr/lib/howdy-gtk/init.py /usr/bin/howdy-gtk 2 | -------------------------------------------------------------------------------- /howdy-gtk/debian/howdy-gtk.lintian-overrides: -------------------------------------------------------------------------------- 1 | # W: Don't require ugly linebreaks in last 5 chars 2 | howdy: debian-changelog-line-too-long 3 | -------------------------------------------------------------------------------- /howdy-gtk/debian/install: -------------------------------------------------------------------------------- 1 | src/. /usr/lib/howdy-gtk 2 | -------------------------------------------------------------------------------- /howdy-gtk/debian/postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | pip3 install elevate 3 | -------------------------------------------------------------------------------- /howdy-gtk/debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | DH_VERBOSE = 1 3 | 4 | DPKG_EXPORT_BUILDFLAGS = 1 5 | include /usr/share/dpkg/default.mk 6 | 7 | %: 8 | dh $@ 9 | -------------------------------------------------------------------------------- /howdy-gtk/debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /howdy-gtk/debian/source/options: -------------------------------------------------------------------------------- 1 | tar-ignore = ".git" 2 | tar-ignore = ".gitignore" 3 | tar-ignore = ".github" 4 | tar-ignore = "README.md" 5 | tar-ignore = ".travis.yml" 6 | tar-ignore = "fedora" 7 | tar-ignore = "opensuse" 8 | tar-ignore = "archlinux" 9 | -------------------------------------------------------------------------------- /howdy-gtk/meson.build: -------------------------------------------------------------------------------- 1 | if meson.is_subproject() 2 | project('howdy-gtk', license: 'MIT', version: 'beta', meson_version: '>= 0.64.0') 3 | endif 4 | 5 | datadir = get_option('prefix') / get_option('datadir') / 'howdy-gtk' 6 | py_conf = configuration_data(paths_dict) 7 | py_conf.set('data_dir', datadir) 8 | 9 | 10 | py_paths = configure_file( 11 | input: 'src/paths.py.in', 12 | output: 'paths.py', 13 | configuration: py_conf, 14 | ) 15 | 16 | sources = files( 17 | 'src/authsticky.py', 18 | 'src/i18n.py', 19 | 'src/init.py', 20 | 'src/onboarding.py', 21 | 'src/paths_factory.py', 22 | 'src/tab_models.py', 23 | 'src/tab_video.py', 24 | 'src/window.py', 25 | ) 26 | 27 | py = import('python').find_installation( 28 | # modules: ['gi', 'elevate'] 29 | ) 30 | py.dependency() 31 | 32 | if get_option('install_in_site_packages') 33 | pysourcesinstalldir = join_paths(py.get_install_dir(), 'howdy-gtk') 34 | else 35 | pysourcesinstalldir = get_option('py_sources_dir') != '' ? get_option('py_sources_dir') / 'howdy-gtk' : join_paths(get_option('prefix'), get_option('libdir'), 'howdy-gtk') 36 | endif 37 | 38 | if get_option('install_in_site_packages') 39 | py.install_sources( 40 | sources, 41 | py_paths, 42 | subdir: 'howdy-gtk', 43 | install_tag: 'py_sources', 44 | ) 45 | else 46 | install_data( 47 | sources, 48 | py_paths, 49 | install_dir: pysourcesinstalldir, 50 | install_mode: 'r--r--r--', 51 | install_tag: 'py_sources', 52 | ) 53 | endif 54 | 55 | logos = files( 56 | 'src/logo.png', 57 | 'src/logo_about.png', 58 | ) 59 | install_data(logos, install_dir: datadir) 60 | 61 | interface_files = files( 62 | 'src/main.glade', 63 | 'src/onboarding.glade', 64 | ) 65 | install_data(interface_files, install_dir: datadir) 66 | 67 | cli_path = join_paths(pysourcesinstalldir, 'init.py') 68 | conf_data = configuration_data({ 'script_path': cli_path, 'python_path': py.full_path() }) 69 | 70 | bin_name = 'howdy-gtk' 71 | bin = configure_file( 72 | input: 'bin/howdy-gtk.in', 73 | output: bin_name, 74 | configuration: conf_data 75 | ) 76 | install_data( 77 | bin, 78 | install_mode: 'rwxr-xr-x', 79 | install_dir: get_option('prefix') / get_option('bindir'), 80 | install_tag: 'bin', 81 | ) 82 | 83 | if get_option('with_polkit') 84 | polkit_config = configure_file( 85 | input: 'src/polkit/com.github.boltgolt.howdy-gtk.policy.in', 86 | output: 'com.github.boltgolt.howdy-gtk.policy', 87 | configuration: {'script_path': cli_path, 'python_path': py.full_path()} 88 | ) 89 | install_data( 90 | polkit_config, 91 | install_dir: get_option('prefix') / get_option('datadir') / 'polkit-1' / 'actions', 92 | install_mode: 'rw-r--r--', 93 | install_tag: 'polkit', 94 | ) 95 | endif 96 | -------------------------------------------------------------------------------- /howdy-gtk/src/authsticky.py: -------------------------------------------------------------------------------- 1 | # Shows a floating window when authenticating 2 | import cairo 3 | import gi 4 | import signal 5 | import sys 6 | import paths_factory 7 | import os 8 | 9 | from i18n import _ 10 | 11 | # Make sure we have the libs we need 12 | gi.require_version("Gtk", "3.0") 13 | gi.require_version("Gdk", "3.0") 14 | 15 | # Import them 16 | from gi.repository import Gtk as gtk 17 | from gi.repository import Gdk as gdk 18 | from gi.repository import GObject as gobject 19 | 20 | # Set window size constants 21 | windowWidth = 400 22 | windowHeight = 100 23 | 24 | 25 | class StickyWindow(gtk.Window): 26 | # Set default messages to show in the popup 27 | message = _("Loading... ") 28 | subtext = "" 29 | 30 | def __init__(self): 31 | """Initialize the sticky window""" 32 | # Make the class a GTK window 33 | gtk.Window.__init__(self) 34 | 35 | # Get the absolute or relative path to the logo file 36 | logo_path = paths_factory.logo_path() 37 | 38 | # Create image and calculate scale size based on image size 39 | self.logo_surface = cairo.ImageSurface.create_from_png(logo_path) 40 | self.logo_ratio = float(windowHeight - 20) / float(self.logo_surface.get_height()) 41 | 42 | # Set the title of the window 43 | self.set_title(_("Howdy Authentication")) 44 | 45 | # Set a bunch of options to make the window stick and be on top of everything 46 | self.stick() 47 | self.set_gravity(gdk.Gravity.STATIC) 48 | self.set_resizable(False) 49 | self.set_keep_above(True) 50 | self.set_app_paintable(True) 51 | self.set_skip_pager_hint(True) 52 | self.set_skip_taskbar_hint(True) 53 | self.set_can_focus(False) 54 | self.set_can_default(False) 55 | self.set_focus(None) 56 | self.set_type_hint(gdk.WindowTypeHint.NOTIFICATION) 57 | self.set_decorated(False) 58 | 59 | # Listen for a window redraw 60 | self.connect("draw", self.draw) 61 | # Listen for a force close or click event and exit 62 | self.connect("destroy", self.exit) 63 | self.connect("delete_event", self.exit) 64 | self.connect("button-press-event", self.exit) 65 | self.connect("button-release-event", self.exit) 66 | 67 | # Create a GDK drawing, restricts the window size 68 | darea = gtk.DrawingArea() 69 | darea.set_size_request(windowWidth, windowHeight) 70 | self.add(darea) 71 | 72 | # Get the default screen 73 | screen = gdk.Screen.get_default() 74 | visual = screen.get_rgba_visual() 75 | self.set_visual(visual) 76 | 77 | # Move the window to the center top of the default window, where a webcam usually is 78 | self.move((screen.get_width() / 2) - (windowWidth / 2), 0) 79 | 80 | # Show window and force a resize again 81 | self.show_all() 82 | self.resize(windowWidth, windowHeight) 83 | 84 | # Add a timeout to catch input passed from compare.py 85 | gobject.timeout_add(100, self.catch_stdin) 86 | 87 | # Start GTK main loop 88 | gtk.main() 89 | 90 | def draw(self, widget, ctx): 91 | """Draw the UI""" 92 | # Change cursor to the kill icon 93 | self.get_window().set_cursor(gdk.Cursor(gdk.CursorType.PIRATE)) 94 | 95 | # Draw a semi transparent background 96 | ctx.set_source_rgba(0, 0, 0, .7) 97 | ctx.set_operator(cairo.OPERATOR_SOURCE) 98 | ctx.paint() 99 | ctx.set_operator(cairo.OPERATOR_OVER) 100 | 101 | # Position and draw the logo 102 | ctx.translate(15, 10) 103 | ctx.scale(self.logo_ratio, self.logo_ratio) 104 | ctx.set_source_surface(self.logo_surface) 105 | ctx.paint() 106 | 107 | # Calculate main message positioning, as the text is higher if there's a subtext 108 | if self.subtext: 109 | ctx.move_to(380, 145) 110 | else: 111 | ctx.move_to(380, 175) 112 | 113 | # Draw the main message 114 | ctx.set_source_rgba(255, 255, 255, .9) 115 | ctx.set_font_size(80) 116 | ctx.select_font_face("Arial", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) 117 | ctx.show_text(self.message) 118 | 119 | # Draw the subtext if there is one 120 | if self.subtext: 121 | ctx.move_to(380, 210) 122 | ctx.set_source_rgba(230, 230, 230, .8) 123 | ctx.set_font_size(40) 124 | ctx.select_font_face("Arial", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) 125 | ctx.show_text(self.subtext) 126 | 127 | def catch_stdin(self): 128 | """Catch input from stdin and redraw""" 129 | # Wait for a line on stdin 130 | comm = sys.stdin.readline()[:-1] 131 | 132 | # If the line is not empty 133 | if comm: 134 | # Parse a message 135 | if comm[0] == "M": 136 | self.message = comm[2:].strip() 137 | # Parse subtext 138 | if comm[0] == "S": 139 | # self.subtext += " " 140 | self.subtext = comm[2:].strip() 141 | 142 | # Redraw the ui 143 | self.queue_draw() 144 | 145 | # Fire this function again in 10ms, as we're waiting on IO in readline anyway 146 | gobject.timeout_add(10, self.catch_stdin) 147 | 148 | def exit(self, widget, context): 149 | """Cleanly exit""" 150 | gtk.main_quit() 151 | return True 152 | 153 | 154 | # Make sure we quit on a SIGINT 155 | signal.signal(signal.SIGINT, signal.SIG_DFL) 156 | 157 | # Open the GTK window 158 | window = StickyWindow() 159 | -------------------------------------------------------------------------------- /howdy-gtk/src/i18n.py: -------------------------------------------------------------------------------- 1 | # Support file for translations 2 | 3 | # Import modules 4 | import gettext 5 | import os 6 | 7 | # Get the right translation based on locale, falling back to base if none found 8 | translation = gettext.translation("gtk", localedir=os.path.join(os.path.dirname(__file__), "locales"), fallback=True) 9 | translation.install() 10 | 11 | # Export translation function as _ 12 | _ = translation.gettext 13 | -------------------------------------------------------------------------------- /howdy-gtk/src/init.py: -------------------------------------------------------------------------------- 1 | # Opens auth ui if requested, otherwise starts normal ui 2 | import sys 3 | 4 | if "--start-auth-ui" in sys.argv: 5 | import authsticky 6 | else: 7 | import window 8 | -------------------------------------------------------------------------------- /howdy-gtk/src/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boltgolt/howdy/c4521c14ab8c672cadbc826a3dbec9ef95b7adb1/howdy-gtk/src/logo.png -------------------------------------------------------------------------------- /howdy-gtk/src/logo_about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boltgolt/howdy/c4521c14ab8c672cadbc826a3dbec9ef95b7adb1/howdy-gtk/src/logo_about.png -------------------------------------------------------------------------------- /howdy-gtk/src/main.glade: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | True 7 | False 8 | 5 9 | gtk-add 10 | 11 | 12 | True 13 | False 14 | 5 15 | gtk-add 16 | 17 | 18 | True 19 | False 20 | 5 21 | gtk-delete 22 | 23 | 24 | False 25 | 5 26 | Howdy Configuration 27 | center 28 | logo.png 29 | 30 | 31 | True 32 | True 33 | 2 34 | left 35 | False 36 | 37 | 38 | 39 | True 40 | False 41 | vertical 42 | 43 | 44 | True 45 | False 46 | 10 47 | 10 48 | 10 49 | 5 50 | 51 | 52 | True 53 | False 54 | center 55 | Showing saved models for: 56 | 57 | 58 | False 59 | False 60 | 0 61 | 62 | 63 | 64 | 65 | True 66 | False 67 | 68 | 69 | 70 | False 71 | False 72 | 1 73 | 74 | 75 | 76 | 77 | Add new user 78 | True 79 | True 80 | True 81 | 15 82 | iconadduser 83 | none 84 | True 85 | 86 | 87 | 88 | False 89 | False 90 | end 91 | 3 92 | 93 | 94 | 95 | 96 | False 97 | True 98 | 0 99 | 100 | 101 | 102 | 103 | True 104 | False 105 | 10 106 | 10 107 | vertical 108 | 109 | 110 | True 111 | False 112 | 10 113 | 10 114 | 115 | 116 | False 117 | True 118 | 0 119 | 120 | 121 | 122 | 123 | False 124 | True 125 | 1 126 | 127 | 128 | 129 | 130 | True 131 | False 132 | 10 133 | 8 134 | 10 135 | 136 | 137 | 138 | 139 | 140 | Add 141 | True 142 | True 143 | True 144 | iconadd 145 | 0.5899999737739563 146 | True 147 | 148 | 149 | 150 | False 151 | True 152 | end 153 | 1 154 | 155 | 156 | 157 | 158 | Delete 159 | True 160 | True 161 | True 162 | 11 163 | icondelete 164 | True 165 | 166 | 167 | 168 | False 169 | True 170 | end 171 | 2 172 | 173 | 174 | 175 | 176 | False 177 | True 178 | end 179 | 2 180 | 181 | 182 | 183 | 184 | 185 | 186 | True 187 | False 188 | 10 189 | 10 190 | Models 191 | 192 | 193 | 1 194 | False 195 | 196 | 197 | 198 | 199 | True 200 | False 201 | 10 202 | 10 203 | 10 204 | 10 205 | 206 | 207 | True 208 | False 209 | 10 210 | vertical 211 | 212 | 213 | True 214 | False 215 | start 216 | Camera ID: 217 | 218 | 219 | 220 | 221 | 222 | False 223 | True 224 | 0 225 | 226 | 227 | 228 | 229 | True 230 | False 231 | start 232 | True 233 | end 234 | 235 | 236 | False 237 | True 238 | 1 239 | 240 | 241 | 242 | 243 | True 244 | False 245 | start 246 | 10 247 | Real resolution: 248 | 249 | 250 | 251 | 252 | 253 | False 254 | True 255 | 2 256 | 257 | 258 | 259 | 260 | True 261 | False 262 | start 263 | True 264 | 265 | 266 | False 267 | True 268 | 3 269 | 270 | 271 | 272 | 273 | True 274 | False 275 | start 276 | 10 277 | Used resolution: 278 | 279 | 280 | 281 | 282 | 283 | False 284 | True 285 | 4 286 | 287 | 288 | 289 | 290 | True 291 | False 292 | start 293 | True 294 | 295 | 296 | False 297 | True 298 | 5 299 | 300 | 301 | 302 | 303 | True 304 | False 305 | start 306 | 10 307 | Recorder: 308 | 309 | 310 | 311 | 312 | 313 | False 314 | True 315 | 6 316 | 317 | 318 | 319 | 320 | True 321 | False 322 | start 323 | True 324 | 325 | 326 | False 327 | True 328 | 7 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | False 337 | True 338 | end 339 | 0 340 | 341 | 342 | 343 | 344 | 300 345 | 300 346 | True 347 | False 348 | 349 | 350 | True 351 | False 352 | gtk-execute 353 | 6 354 | 355 | 356 | 357 | 358 | True 359 | True 360 | 1 361 | 362 | 363 | 364 | 365 | 1 366 | 367 | 368 | 369 | 370 | True 371 | False 372 | Video 373 | 374 | 375 | 1 376 | False 377 | 378 | 379 | 380 | 381 | True 382 | False 383 | center 384 | center 385 | vertical 386 | 387 | 388 | True 389 | False 390 | 20 391 | 12 392 | logo_about.png 393 | 394 | 395 | False 396 | True 397 | 0 398 | 399 | 400 | 401 | 402 | True 403 | False 404 | Howdy 405 | 406 | 407 | 408 | 409 | 410 | False 411 | True 412 | 1 413 | 414 | 415 | 416 | 417 | True 418 | False 419 | 5 420 | Facial authentication for Linux 421 | 422 | 423 | False 424 | True 425 | 2 426 | 427 | 428 | 429 | 430 | True 431 | False 432 | center 433 | center 434 | 15 435 | 35 436 | 437 | 438 | True 439 | False 440 | 3 441 | <a href="https://github.com/boltgolt/howdy">Open GitHub link</a> 442 | True 443 | False 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | False 453 | True 454 | 1 455 | 456 | 457 | 458 | 459 | True 460 | False 461 | 3 462 | <a href="https://www.buymeacoffee.com/boltgolt">Donate to the project</a> 463 | True 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | False 473 | True 474 | 2 475 | 476 | 477 | 478 | 479 | False 480 | True 481 | 3 482 | 483 | 484 | 485 | 486 | 2 487 | 488 | 489 | 490 | 491 | True 492 | False 493 | About 494 | 495 | 496 | 2 497 | False 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | -------------------------------------------------------------------------------- /howdy-gtk/src/onboarding.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import re 4 | import time 5 | import subprocess 6 | import paths_factory 7 | 8 | from i18n import _ 9 | 10 | from gi.repository import Gtk as gtk 11 | from gi.repository import Gdk as gdk 12 | from gi.repository import GObject as gobject 13 | from gi.repository import Pango as pango 14 | 15 | 16 | class OnboardingWindow(gtk.Window): 17 | def __init__(self): 18 | """Initialize the sticky window""" 19 | # Make the class a GTK window 20 | gtk.Window.__init__(self) 21 | 22 | self.builder = gtk.Builder() 23 | self.builder.add_from_file(paths_factory.onboarding_wireframe_path()) 24 | self.builder.connect_signals(self) 25 | 26 | self.window = self.builder.get_object("onboardingwindow") 27 | self.slidecontainer = self.builder.get_object("slidecontainer") 28 | self.nextbutton = self.builder.get_object("nextbutton") 29 | 30 | self.window.connect("destroy", self.exit) 31 | self.window.connect("delete_event", self.exit) 32 | 33 | self.slides = [ 34 | self.builder.get_object("slide0"), 35 | self.builder.get_object("slide1"), 36 | self.builder.get_object("slide2"), 37 | self.builder.get_object("slide3"), 38 | self.builder.get_object("slide4"), 39 | self.builder.get_object("slide5"), 40 | self.builder.get_object("slide6") 41 | ] 42 | 43 | self.window.show_all() 44 | self.window.resize(500, 400) 45 | 46 | self.window.current_slide = 0 47 | 48 | # Start GTK main loop 49 | gtk.main() 50 | 51 | def go_next_slide(self, button=None): 52 | self.nextbutton.set_sensitive(False) 53 | 54 | self.slides[self.window.current_slide].hide() 55 | self.slides[self.window.current_slide + 1].show() 56 | self.window.current_slide += 1 57 | # the shown child may have zero/wrong dimensions 58 | self.slidecontainer.queue_resize() 59 | 60 | if self.window.current_slide == 1: 61 | self.execute_slide1() 62 | elif self.window.current_slide == 2: 63 | gobject.timeout_add(10, self.execute_slide2) 64 | elif self.window.current_slide == 3: 65 | self.execute_slide3() 66 | elif self.window.current_slide == 4: 67 | self.execute_slide4() 68 | elif self.window.current_slide == 5: 69 | self.execute_slide5() 70 | elif self.window.current_slide == 6: 71 | self.execute_slide6() 72 | 73 | def execute_slide1(self): 74 | self.downloadoutputlabel = self.builder.get_object("downloadoutputlabel") 75 | eventbox = self.builder.get_object("downloadeventbox") 76 | eventbox.modify_bg(gtk.StateType.NORMAL, gdk.Color(red=0, green=0, blue=0)) 77 | 78 | # TODO: Better way to do this? 79 | if os.path.exists(paths_factory.dlib_data_dir_path() / "shape_predictor_5_face_landmarks.dat"): 80 | self.downloadoutputlabel.set_text(_("Datafiles have already been downloaded!\nClick Next to continue")) 81 | self.enable_next() 82 | return 83 | 84 | self.proc = subprocess.Popen("./install.sh", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=paths_factory.dlib_data_dir_path()) 85 | 86 | self.download_lines = [] 87 | self.read_download_line() 88 | 89 | def read_download_line(self): 90 | line = self.proc.stdout.readline() 91 | self.download_lines.append(line.decode("utf-8")) 92 | 93 | print("install.sh output:") 94 | print(line.decode("utf-8")) 95 | 96 | if len(self.download_lines) > 10: 97 | self.download_lines.pop(0) 98 | 99 | self.downloadoutputlabel.set_text(" ".join(self.download_lines)) 100 | 101 | if line: 102 | gobject.timeout_add(10, self.read_download_line) 103 | return 104 | 105 | # Wait for the process to finish and check the status code 106 | if self.proc.wait(5) != 0: 107 | self.show_error(_("Error while downloading datafiles"), " ".join(self.download_lines)) 108 | 109 | self.downloadoutputlabel.set_text(_("Done!\nClick Next to continue")) 110 | self.enable_next() 111 | 112 | def execute_slide2(self): 113 | def is_gray(frame): 114 | for row in frame: 115 | for pixel in row: 116 | if not pixel[0] == pixel[1] == pixel[2]: 117 | return False 118 | return True 119 | 120 | try: 121 | import cv2 122 | except Exception: 123 | self.show_error(_("Error while importing OpenCV2"), _("Try reinstalling cv2")) 124 | 125 | device_rows = [] 126 | try: 127 | device_ids = os.listdir("/dev/v4l/by-path") 128 | except Exception: 129 | self.show_error(_("No webcams found on system"), _("Please configure your camera yourself if you are sure a compatible camera is connected")) 130 | 131 | # Loop though all devices 132 | for dev in device_ids: 133 | time.sleep(.5) 134 | 135 | # The full path to the device is the default name 136 | device_path = "/dev/v4l/by-path/" + dev 137 | device_name = dev 138 | 139 | # Get the udevadm details to try to get a better name 140 | udevadm = subprocess.check_output(["udevadm info -r --query=all -n " + device_path], shell=True).decode("utf-8") 141 | 142 | # Loop though udevadm to search for a better name 143 | for line in udevadm.split("\n"): 144 | # Match it and encase it in quotes 145 | re_name = re.search('product.*=(.*)$', line, re.IGNORECASE) 146 | if re_name: 147 | device_name = re_name.group(1) 148 | 149 | capture = cv2.VideoCapture(device_path) 150 | is_open, frame = capture.read() 151 | if not is_open: 152 | device_rows.append([device_name, device_path, -9, _("No, camera can't be opened")]) 153 | continue 154 | 155 | try: 156 | if not is_gray(frame): 157 | raise Exception() 158 | except Exception: 159 | device_rows.append([device_name, device_path, -5, _("No, not an infrared camera")]) 160 | capture.release() 161 | continue 162 | 163 | device_rows.append([device_name, device_path, 5, _("Yes, compatible infrared camera")]) 164 | capture.release() 165 | 166 | device_rows = sorted(device_rows, key=lambda k: -k[2]) 167 | 168 | self.loadinglabel = self.builder.get_object("loadinglabel") 169 | self.devicelistbox = self.builder.get_object("devicelistbox") 170 | 171 | self.treeview = gtk.TreeView() 172 | self.treeview.set_vexpand(True) 173 | 174 | # Set the columns 175 | for i, column in enumerate([_("Camera identifier or path"), _("Recommended")]): 176 | cell = gtk.CellRendererText() 177 | cell.set_property("ellipsize", pango.EllipsizeMode.END) 178 | col = gtk.TreeViewColumn(column, cell, text=i) 179 | self.treeview.append_column(col) 180 | 181 | # Add the treeview 182 | self.devicelistbox.add(self.treeview) 183 | 184 | # Create a datamodel 185 | self.listmodel = gtk.ListStore(str, str, str, bool) 186 | 187 | for device in device_rows: 188 | is_gray = device[2] == 5 189 | self.listmodel.append([device[0], device[3], device[1], is_gray]) 190 | 191 | self.treeview.set_model(self.listmodel) 192 | self.treeview.set_cursor(0) 193 | 194 | self.treeview.show() 195 | self.loadinglabel.hide() 196 | self.enable_next() 197 | 198 | def execute_slide3(self): 199 | try: 200 | import cv2 201 | except Exception: 202 | self.show_error(_("Error while importing OpenCV2"), _("Try reinstalling cv2")) 203 | 204 | selection = self.treeview.get_selection() 205 | (listmodel, rowlist) = selection.get_selected_rows() 206 | 207 | if len(rowlist) != 1: 208 | self.show_error(_("Error selecting camera")) 209 | 210 | device_path = listmodel.get_value(listmodel.get_iter(rowlist[0]), 2) 211 | is_gray = listmodel.get_value(listmodel.get_iter(rowlist[0]), 3) 212 | 213 | if is_gray: 214 | # test if linux-enable-ir-emitter help should be displayed, 215 | # the user must click on the yes/no button which calls the method slide3_button_yes|no 216 | self.capture = cv2.VideoCapture(device_path) 217 | if not self.capture.isOpened(): 218 | self.show_error(_("The selected camera cannot be opened"), _("Try to select another one")) 219 | self.capture.read() 220 | else: 221 | # skip, the selected camera is not infrared 222 | self.go_next_slide() 223 | 224 | def slide3_button_yes(self, button): 225 | self.capture.release() 226 | self.go_next_slide() 227 | 228 | def slide3_button_no(self, button): 229 | self.capture.release() 230 | self.builder.get_object("leiestatus").set_markup(_("Please visit\nhttps://github.com/EmixamPP/linux-enable-ir-emitter\nto enable your ir emitter")) 231 | self.builder.get_object("leieyesbutton").hide() 232 | self.builder.get_object("leienobutton").hide() 233 | 234 | def execute_slide4(self): 235 | selection = self.treeview.get_selection() 236 | (listmodel, rowlist) = selection.get_selected_rows() 237 | 238 | if len(rowlist) != 1: 239 | self.show_error(_("Error selecting camera")) 240 | 241 | device_path = listmodel.get_value(listmodel.get_iter(rowlist[0]), 2) 242 | self.proc = subprocess.Popen("howdy set device_path " + device_path, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True) 243 | 244 | self.window.set_focus(self.builder.get_object("scanbutton")) 245 | 246 | def on_scanbutton_click(self, button): 247 | status = self.proc.wait(2) 248 | 249 | # if status != 0: 250 | # self.show_error(_("Error setting camera path"), _("Please set the camera path manually")) 251 | 252 | self.dialog = gtk.MessageDialog(parent=self, flags=gtk.DialogFlags.MODAL) 253 | self.dialog.set_title(_("Creating Model")) 254 | self.dialog.props.text = _("Please look directly into the camera") 255 | self.dialog.show_all() 256 | 257 | # Wait a bit to allow the user to read the dialog 258 | gobject.timeout_add(600, self.run_add) 259 | 260 | def run_add(self): 261 | status, output = subprocess.getstatusoutput(["howdy add -y"]) 262 | 263 | print("howdy add output:") 264 | print(output) 265 | 266 | self.dialog.destroy() 267 | 268 | if status != 0: 269 | self.show_error(_("Can't save face model"), output) 270 | 271 | gobject.timeout_add(10, self.go_next_slide) 272 | 273 | def execute_slide5(self): 274 | self.enable_next() 275 | 276 | def execute_slide6(self): 277 | radio_buttons = self.builder.get_object("radiobalanced").get_group() 278 | radio_selected = False 279 | radio_certanty = 5.0 280 | 281 | for button in radio_buttons: 282 | if button.get_active(): 283 | radio_selected = gtk.Buildable.get_name(button) 284 | 285 | if not radio_selected: 286 | self.show_error(_("Error reading radio buttons")) 287 | elif radio_selected == "radiofast": 288 | radio_certanty = 4.2 289 | elif radio_selected == "radiobalanced": 290 | radio_certanty = 3.5 291 | elif radio_selected == "radiosecure": 292 | radio_certanty = 2.2 293 | 294 | self.proc = subprocess.Popen("howdy set certainty " + str(radio_certanty), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True) 295 | 296 | self.nextbutton.hide() 297 | self.builder.get_object("cancelbutton").hide() 298 | 299 | finishbutton = self.builder.get_object("finishbutton") 300 | finishbutton.show() 301 | self.window.set_focus(finishbutton) 302 | 303 | status = self.proc.wait(2) 304 | 305 | if status != 0: 306 | self.show_error(_("Error setting certainty"), _("Certainty is set to the default value, Howdy setup is complete")) 307 | 308 | def enable_next(self): 309 | self.nextbutton.set_sensitive(True) 310 | self.window.set_focus(self.nextbutton) 311 | 312 | def show_error(self, error, secon=""): 313 | dialog = gtk.MessageDialog(parent=self, flags=gtk.DialogFlags.MODAL, type=gtk.MessageType.ERROR, buttons=gtk.ButtonsType.CLOSE) 314 | dialog.set_title(_("Howdy Error")) 315 | dialog.props.text = error 316 | dialog.format_secondary_text(secon) 317 | 318 | dialog.run() 319 | 320 | dialog.destroy() 321 | self.exit() 322 | 323 | def exit(self, widget=None, context=None): 324 | """Cleanly exit""" 325 | gtk.main_quit() 326 | sys.exit(0) 327 | -------------------------------------------------------------------------------- /howdy-gtk/src/paths.py.in: -------------------------------------------------------------------------------- 1 | from pathlib import PurePath 2 | 3 | # Define the absolute path to the config directory 4 | config_dir = PurePath("@config_dir@") 5 | 6 | # Define the absolute path to the DLib models data directory 7 | dlib_data_dir = PurePath("@dlib_data_dir@") 8 | 9 | # Define the absolute path to the Howdy user models directory 10 | user_models_dir = PurePath("@user_models_dir@") 11 | 12 | # Define the absolute path to the Howdy data directory 13 | data_dir = PurePath("@data_dir@") -------------------------------------------------------------------------------- /howdy-gtk/src/paths_factory.py: -------------------------------------------------------------------------------- 1 | from pathlib import PurePath 2 | import paths 3 | 4 | 5 | def config_file_path() -> str: 6 | """Return the path to the config file""" 7 | return str(paths.config_dir / "config.ini") 8 | 9 | 10 | def user_models_dir_path() -> PurePath: 11 | """Return the path to the user models directory""" 12 | return paths.user_models_dir 13 | 14 | 15 | def logo_path() -> str: 16 | """Return the path to the logo file""" 17 | return str(paths.data_dir / "logo.png") 18 | 19 | 20 | def onboarding_wireframe_path() -> str: 21 | """Return the path to the onboarding wireframe file""" 22 | return str(paths.data_dir / "onboarding.glade") 23 | 24 | 25 | def main_window_wireframe_path() -> str: 26 | """Return the path to the main window wireframe file""" 27 | return str(paths.data_dir / "main.glade") 28 | 29 | 30 | def dlib_data_dir_path() -> PurePath: 31 | """Return the path to the dlib data directory""" 32 | return paths.dlib_data_dir 33 | -------------------------------------------------------------------------------- /howdy-gtk/src/polkit/com.github.boltgolt.howdy-gtk.policy.in: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | boltgolt 6 | https://github.com/boltgolt/howdy 7 | howdy-gtk 8 | 9 | Howdy interface 10 | Authentication is required to run howdy-gtk 11 | 12 | no 13 | no 14 | auth_admin 15 | 16 | @python_path@ 17 | @script_path@ 18 | true 19 | 20 | -------------------------------------------------------------------------------- /howdy-gtk/src/tab_models.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import time 3 | 4 | from i18n import _ 5 | from gi.repository import Gtk as gtk 6 | from gi.repository import GObject as gobject 7 | 8 | 9 | def on_user_change(self, select): 10 | self.active_user = select.get_active_text() 11 | self.load_model_list() 12 | 13 | 14 | def on_user_add(self, button): 15 | # Open question dialog 16 | dialog = gtk.MessageDialog(parent=self, flags=gtk.DialogFlags.MODAL, type=gtk.MessageType.QUESTION, buttons=gtk.ButtonsType.OK_CANCEL) 17 | dialog.set_title(_("Confirm User Creation")) 18 | dialog.props.text = _("Please enter the username of the user you want to add to Howdy") 19 | 20 | # Create the input field 21 | entry = gtk.Entry() 22 | 23 | # Add a label to ask for a model name 24 | hbox = gtk.HBox() 25 | hbox.pack_start(gtk.Label(_("Username:")), False, 5, 5) 26 | hbox.pack_end(entry, True, True, 5) 27 | 28 | # Add the box and show the dialog 29 | dialog.vbox.pack_end(hbox, True, True, 0) 30 | dialog.show_all() 31 | 32 | # Show dialog 33 | response = dialog.run() 34 | 35 | entered_user = entry.get_text() 36 | dialog.destroy() 37 | 38 | if response == gtk.ResponseType.OK: 39 | self.userlist.append_text(entered_user) 40 | self.userlist.set_active(self.userlist.items) 41 | self.userlist.items += 1 42 | 43 | self.active_user = entered_user 44 | self.load_model_list() 45 | 46 | 47 | def on_model_add(self, button): 48 | if self.userlist.items == 0: 49 | return 50 | # Open question dialog 51 | dialog = gtk.MessageDialog(parent=self, flags=gtk.DialogFlags.MODAL, type=gtk.MessageType.QUESTION, buttons=gtk.ButtonsType.OK_CANCEL) 52 | dialog.set_title(_("Confirm Model Creation")) 53 | dialog.props.text = _("Please enter a name for the new model, 24 characters max") 54 | 55 | # Create the input field 56 | entry = gtk.Entry() 57 | 58 | # Add a label to ask for a model name 59 | hbox = gtk.HBox() 60 | hbox.pack_start(gtk.Label(_("Model name:")), False, 5, 5) 61 | hbox.pack_end(entry, True, True, 5) 62 | 63 | # Add the box and show the dialog 64 | dialog.vbox.pack_end(hbox, True, True, 0) 65 | dialog.show_all() 66 | 67 | # Show dialog 68 | response = dialog.run() 69 | 70 | entered_name = entry.get_text() 71 | dialog.destroy() 72 | 73 | if response == gtk.ResponseType.OK: 74 | dialog = gtk.MessageDialog(parent=self, flags=gtk.DialogFlags.MODAL, buttons=gtk.ButtonsType.NONE) 75 | dialog.set_title(_("Creating Model")) 76 | dialog.props.text = _("Please look directly into the camera") 77 | dialog.show_all() 78 | 79 | # Wait a bit to allow the user to read the dialog 80 | gobject.timeout_add(600, lambda: execute_add(self, dialog, entered_name)) 81 | 82 | 83 | def execute_add(box, dialog, entered_name): 84 | 85 | status, output = subprocess.getstatusoutput(["howdy add '" + entered_name + "' -y -U " + box.active_user]) 86 | 87 | dialog.destroy() 88 | 89 | if status != 0: 90 | dialog = gtk.MessageDialog(parent=box, flags=gtk.DialogFlags.MODAL, type=gtk.MessageType.ERROR, buttons=gtk.ButtonsType.CLOSE) 91 | dialog.set_title(_("Howdy Error")) 92 | dialog.props.text = _("Error while adding model, error code {}: \n\n").format(str(status)) 93 | dialog.format_secondary_text(output) 94 | dialog.run() 95 | dialog.destroy() 96 | 97 | box.load_model_list() 98 | 99 | def on_model_delete(self, button): 100 | selection = self.treeview.get_selection() 101 | (listmodel, rowlist) = selection.get_selected_rows() 102 | 103 | if len(rowlist) == 1: 104 | id = listmodel.get_value(listmodel.get_iter(rowlist[0]), 0) 105 | name = listmodel.get_value(listmodel.get_iter(rowlist[0]), 2) 106 | 107 | dialog = gtk.MessageDialog(parent=self, flags=gtk.DialogFlags.MODAL, buttons=gtk.ButtonsType.OK_CANCEL) 108 | dialog.set_title(_("Confirm Model Deletion")) 109 | dialog.props.text = _("Are you sure you want to delete model {id} ({name})?").format(id=id, name=name) 110 | response = dialog.run() 111 | dialog.destroy() 112 | 113 | if response == gtk.ResponseType.OK: 114 | status, output = subprocess.getstatusoutput(["howdy remove " + id + " -y -U " + self.active_user]) 115 | 116 | if status != 0: 117 | dialog = gtk.MessageDialog(parent=self, flags=gtk.DialogFlags.MODAL, type=gtk.MessageType.ERROR, buttons=gtk.ButtonsType.CLOSE) 118 | dialog.set_title(_("Howdy Error")) 119 | dialog.props.text = _("Error while deleting model, error code {}: \n\n").format(status) 120 | dialog.format_secondary_text(output) 121 | dialog.run() 122 | dialog.destroy() 123 | 124 | self.load_model_list() 125 | -------------------------------------------------------------------------------- /howdy-gtk/src/tab_video.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | 3 | from i18n import _ 4 | import paths_factory 5 | 6 | from gi.repository import Gtk as gtk 7 | from gi.repository import Gdk as gdk 8 | from gi.repository import GdkPixbuf as pixbuf 9 | from gi.repository import GObject as gobject 10 | 11 | MAX_HEIGHT = 300 12 | MAX_WIDTH = 300 13 | 14 | 15 | def on_page_switch(self, notebook, page, page_num): 16 | if page_num == 1: 17 | 18 | try: 19 | self.config = configparser.ConfigParser() 20 | self.config.read(paths_factory.config_file_path()) 21 | except Exception: 22 | print(_("Can't open camera")) 23 | 24 | path = self.config.get("video", "device_path") 25 | 26 | try: 27 | # if not self.cv2: 28 | import cv2 29 | self.cv2 = cv2 30 | except Exception: 31 | print(_("Can't import OpenCV2")) 32 | 33 | try: 34 | self.capture = cv2.VideoCapture(path) 35 | except Exception: 36 | print(_("Can't open camera")) 37 | 38 | opencvbox = self.builder.get_object("opencvbox") 39 | opencvbox.modify_bg(gtk.StateType.NORMAL, gdk.Color(red=0, green=0, blue=0)) 40 | 41 | height = self.capture.get(self.cv2.CAP_PROP_FRAME_HEIGHT) or 1 42 | width = self.capture.get(self.cv2.CAP_PROP_FRAME_WIDTH) or 1 43 | 44 | self.scaling_factor = (MAX_HEIGHT / height) or 1 45 | 46 | if width * self.scaling_factor > MAX_WIDTH: 47 | self.scaling_factor = (MAX_WIDTH / width) or 1 48 | 49 | config_height = self.config.getfloat("video", "max_height", fallback=320.0) 50 | config_scaling = (config_height / height) or 1 51 | 52 | self.builder.get_object("videoid").set_text(path.split("/")[-1]) 53 | self.builder.get_object("videores").set_text(str(int(width)) + "x" + str(int(height))) 54 | self.builder.get_object("videoresused").set_text(str(int(width * config_scaling)) + "x" + str(int(height * config_scaling))) 55 | self.builder.get_object("videorecorder").set_text(self.config.get("video", "recording_plugin", fallback=_("Unknown"))) 56 | 57 | gobject.timeout_add(10, self.capture_frame) 58 | 59 | elif self.capture is not None: 60 | self.capture.release() 61 | self.capture = None 62 | 63 | 64 | def capture_frame(self): 65 | if self.capture is None: 66 | return 67 | 68 | ret, frame = self.capture.read() 69 | 70 | frame = self.cv2.resize(frame, None, fx=self.scaling_factor, fy=self.scaling_factor, interpolation=self.cv2.INTER_AREA) 71 | 72 | retval, buffer = self.cv2.imencode(".png", frame) 73 | 74 | loader = pixbuf.PixbufLoader() 75 | loader.write(buffer) 76 | loader.close() 77 | buffer = loader.get_pixbuf() 78 | 79 | self.opencvimage.set_from_pixbuf(buffer) 80 | 81 | gobject.timeout_add(20, self.capture_frame) 82 | -------------------------------------------------------------------------------- /howdy-gtk/src/window.py: -------------------------------------------------------------------------------- 1 | # Opens and controls main ui window 2 | import gi 3 | import signal 4 | import sys 5 | import os 6 | import elevate 7 | import subprocess 8 | 9 | from i18n import _ 10 | import paths_factory 11 | 12 | # Make sure we have the libs we need 13 | gi.require_version("Gtk", "3.0") 14 | gi.require_version("Gdk", "3.0") 15 | 16 | # Import them 17 | from gi.repository import Gtk as gtk 18 | 19 | 20 | class MainWindow(gtk.Window): 21 | def __init__(self): 22 | """Initialize the sticky window""" 23 | # Make the class a GTK window 24 | gtk.Window.__init__(self) 25 | 26 | self.builder = gtk.Builder() 27 | self.builder.add_from_file(paths_factory.main_window_wireframe_path()) 28 | self.builder.connect_signals(self) 29 | 30 | self.window = self.builder.get_object("mainwindow") 31 | self.userlist = self.builder.get_object("userlist") 32 | self.modellistbox = self.builder.get_object("modellistbox") 33 | self.opencvimage = self.builder.get_object("opencvimage") 34 | 35 | self.window.connect("destroy", self.exit) 36 | self.window.connect("delete_event", self.exit) 37 | 38 | # Init capture for video tab 39 | self.capture = None 40 | 41 | # Create a treeview that will list the model data 42 | self.treeview = gtk.TreeView() 43 | self.treeview.set_vexpand(True) 44 | 45 | # Set the columns 46 | for i, column in enumerate([_("ID"), _("Created"), _("Label")]): 47 | col = gtk.TreeViewColumn(column, gtk.CellRendererText(), text=i) 48 | self.treeview.append_column(col) 49 | 50 | # Add the treeview 51 | self.modellistbox.add(self.treeview) 52 | 53 | filelist = os.listdir(paths_factory.user_models_dir_path()) 54 | self.active_user = "" 55 | 56 | self.userlist.items = 0 57 | 58 | for file in filelist: 59 | self.userlist.append_text(file[:-4]) 60 | self.userlist.items += 1 61 | 62 | if not self.active_user: 63 | self.active_user = file[:-4] 64 | 65 | self.userlist.set_active(0) 66 | 67 | self.window.show_all() 68 | 69 | # Start GTK main loop 70 | gtk.main() 71 | 72 | def load_model_list(self): 73 | """(Re)load the model list""" 74 | 75 | # Get username and default to none if there are no models at all yet 76 | user = 'none' 77 | if self.active_user: user = self.active_user 78 | 79 | # Execute the list command to get the models 80 | status, output = subprocess.getstatusoutput(["howdy list --plain -U " + user]) 81 | 82 | # Create a datamodel 83 | self.listmodel = gtk.ListStore(str, str, str) 84 | 85 | # If there was no error 86 | if status == 0: 87 | # Split the output per line 88 | lines = output.split("\n") 89 | 90 | # Add the models to the datamodel 91 | for i in range(len(lines)): 92 | items = lines[i].split(",") 93 | if len(items) < 3: continue 94 | self.listmodel.append(items) 95 | 96 | self.treeview.set_model(self.listmodel) 97 | 98 | def on_about_link(self, label, uri): 99 | """Open links on about page as a non-root user""" 100 | try: 101 | user = os.getlogin() 102 | except Exception: 103 | user = os.environ.get("SUDO_USER") 104 | 105 | status, output = subprocess.getstatusoutput(["sudo -u " + user + " timeout 10 xdg-open " + uri]) 106 | return True 107 | 108 | def exit(self, widget=None, context=None): 109 | """Cleanly exit""" 110 | if self.capture is not None: 111 | self.capture.release() 112 | 113 | gtk.main_quit() 114 | sys.exit(0) 115 | 116 | 117 | # Make sure we quit on a SIGINT 118 | signal.signal(signal.SIGINT, signal.SIG_DFL) 119 | 120 | # Make sure we run as sudo 121 | elevate.elevate() 122 | 123 | # If no models have been created yet or when it is forced, start the onboarding 124 | if "--force-onboarding" in sys.argv or not os.path.exists(paths_factory.user_models_dir_path()): 125 | import onboarding 126 | onboarding.OnboardingWindow() 127 | 128 | sys.exit(0) 129 | 130 | # Class is split so it isn't too long, import split functions 131 | import tab_models 132 | MainWindow.on_user_add = tab_models.on_user_add 133 | MainWindow.on_user_change = tab_models.on_user_change 134 | MainWindow.on_model_add = tab_models.on_model_add 135 | MainWindow.on_model_delete = tab_models.on_model_delete 136 | import tab_video 137 | MainWindow.on_page_switch = tab_video.on_page_switch 138 | MainWindow.capture_frame = tab_video.capture_frame 139 | 140 | # Open the GTK window 141 | window = MainWindow() 142 | -------------------------------------------------------------------------------- /howdy/archlinux/.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | src 3 | *.tar.gz 4 | *.zip 5 | *.tar.xz 6 | *.patch 7 | *.dat.bz2 -------------------------------------------------------------------------------- /howdy/archlinux/howdy/.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | src 3 | *.tar.gz 4 | *.zip 5 | *.tar.xz 6 | *.patch 7 | *.dat.bz2 8 | .SRCINFO 9 | -------------------------------------------------------------------------------- /howdy/archlinux/howdy/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Frank Tackitt 2 | # Maintainer: boltgolt 3 | # Co-Maintainer: Raymo111 4 | # Contributor: Kelley McChesney 5 | 6 | pkgname=howdy 7 | pkgver=2.6.1 8 | pkgrel=1 9 | pkgdesc="Windows Hello for Linux" 10 | arch=('x86_64') 11 | url="https://github.com/boltgolt/howdy" 12 | license=('MIT') 13 | depends=( 14 | 'opencv' 15 | 'hdf5' 16 | 'pam-python' 17 | 'python3' 18 | 'python-dlib' 19 | 'python-numpy' 20 | 'python-opencv' 21 | ) 22 | makedepends=( 23 | 'cmake' 24 | 'pkgfile' 25 | ) 26 | backup=('etc/howdy/config.ini') 27 | source=( 28 | "$pkgname-$pkgver.tar.gz::https://github.com/boltgolt/howdy/archive/v${pkgver}.tar.gz" 29 | "https://github.com/davisking/dlib-models/raw/master/dlib_face_recognition_resnet_model_v1.dat.bz2" 30 | "https://github.com/davisking/dlib-models/raw/master/mmod_human_face_detector.dat.bz2" 31 | "https://github.com/davisking/dlib-models/raw/master/shape_predictor_5_face_landmarks.dat.bz2" 32 | ) 33 | sha256sums=('f3f48599f78fd82b049539fcfc34de25c9435cad732697bdda94e85352964794' 34 | 'abb1f61041e434465855ce81c2bd546e830d28bcbed8d27ffbe5bb408b11553a' 35 | 'db9e9e40f092c118d5eb3e643935b216838170793559515541c56a2b50d9fc84' 36 | '6e787bbebf5c9efdb793f6cd1f023230c4413306605f24f299f12869f95aa472') 37 | 38 | package() { 39 | # Installing the proper license files and the rest of howdy 40 | cd howdy-$pkgver 41 | install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" 42 | mkdir -p "$pkgdir/usr/etc/howdy" 43 | mkdir -p "$pkgdir/etc/howdy" 44 | cp -r src/* "$pkgdir/usr/etc/howdy" 45 | cp -r src/config.ini "$pkgdir/etc/howdy" 46 | cp "${srcdir}/dlib_face_recognition_resnet_model_v1.dat" "$pkgdir/usr/etc/howdy/dlib-data/" 47 | cp "${srcdir}/mmod_human_face_detector.dat" "$pkgdir/usr/etc/howdy/dlib-data/" 48 | cp "${srcdir}/shape_predictor_5_face_landmarks.dat" "$pkgdir/usr/etc/howdy/dlib-data/" 49 | chmod 600 -R "$pkgdir/usr/etc/howdy" 50 | mkdir -p "$pkgdir/usr/bin" 51 | ln -s /etc/howdy/cli.py "$pkgdir/usr/bin/howdy" 52 | chmod +x "$pkgdir/etc/howdy/cli.py" 53 | mkdir -p "$pkgdir/usr/share/bash-completion/completions" 54 | cp autocomplete/howdy "$pkgdir/usr/share/bash-completion/completions/howdy" 55 | } 56 | -------------------------------------------------------------------------------- /howdy/debian/changelog: -------------------------------------------------------------------------------- 1 | howdy (2.6.1) xenial; urgency=medium 2 | 3 | * Fixed accidentally using emergency priority for log messages (thanks @kageurufu and many others!) 4 | * Fixed certainty prompt selected the exact opposite value 5 | * Fixed sleeping for negative time in test slow mode (thanks @willwill2will54!) 6 | * Fixed opencv error when imported after dlib (thanks @cnyk!) 7 | * Fixed typo causing manual exposure failure (thanks @h45h74x!) 8 | * Fixed missing command autocomplete options on tab 9 | * Fixed not knowing how to spell the word latest (thanks @divykj!) 10 | 11 | -- boltgolt Wed, 02 Sep 2020 15:05:59 +0200 12 | 13 | howdy (2.6.0) xenial; urgency=medium 14 | 15 | * Added new options to capture a snapshot of failed or even successful logins 16 | * Added command that creates a new snapshot and saves it 17 | * Added version command 18 | * Added question to automatically set certainty value on installation 19 | * Added automatic logging to system-wide auth.log 20 | * Added clearer feedback when login is rejected due to dark frames (thanks @andrewmv!) 21 | * Refactored video capture logic (thanks @AnthonyWharton!) 22 | * Reordered the editor priorities for the config command 23 | * Fixed gstreamer warnings showing up in console (thanks @ajnart!) 24 | * Fixed issue where add command would never end 25 | * Fixed test command overlay not being in color (thanks @PetePriority!) 26 | * Fixed typo preventing timeout config option from working (thanks @Ajayneethikannan!) 27 | * Fixed old numpy installation failure (thanks @rushabh-v!) 28 | * Fixed issue where no PAM response would be returned 29 | * Fixed CLAHE not being applied equally to all video commands (thanks @PetePriority!) 30 | * Fixed an incorrect suggested command (thanks @TheButlah!) 31 | * Fixed missing release method in video capture class 32 | * Removed deprecated dlib flags (thanks @rhysperry111!) 33 | * Removed streamer as a required dependency 34 | 35 | -- boltgolt Mon, 22 Jun 2020 16:11:46 +0200 36 | 37 | howdy (2.5.1) xenial; urgency=medium 38 | 39 | * Removed dismiss_lockscreen as it could lock users out of their system (thanks @ujjwalbe, @ju916 and many others!) 40 | * Added option to disable howdy when the laptop lid is closed (thanks @accek!) 41 | * Added automatic fallback to default frame color palette (thanks @Ethiarpus!) 42 | * Added manual exposure setting (thanks @accek!) 43 | * Fixed test command ignoring dark frame threshold (thanks @eduncan911!) 44 | * Fixed import error in v4l2 recorder (thanks @timwelch!) 45 | 46 | -- boltgolt Fri, 29 Mar 2019 23:02:21 +0100 47 | 48 | howdy (2.5.0) xenial; urgency=medium 49 | 50 | * Added FFmpeg and v4l2 recorders (thanks @timwelch!) 51 | * Added automatic PAM inclusion on installation 52 | * Added optional notice on detection attempt (thanks @mrkmg!) 53 | * Added support for grayscale frame encoding (thanks @dmig and @sapjunior!) 54 | * Massively improved recognition speed (thanks @dmig!) 55 | * Fixed typo in "timout" config value 56 | * Removed unneeded dependencies (thanks @dmig!) 57 | 58 | -- boltgolt Sun, 06 Jan 2019 14:37:41 +0100 59 | 60 | howdy (2.4.0) xenial; urgency=medium 61 | 62 | * Cameras are now selected by path instead of by video device number (thanks @Rhiyo!) 63 | * Added fallbacks to $EDITOR for the config command (thanks @yassineim!) 64 | * Fixed missing cv2 module after installation (thanks @bendandersen and many others!) 65 | * Fixed file permissions crashing Howdy in some cases (thanks @GJDitchfield!) 66 | * Fixed howdy using python3 from local virtual environment (thanks @EdwardJB!) 67 | 68 | -- boltgolt Fri, 09 Nov 2018 20:59:45 +0100 69 | 70 | howdy (2.3.1) xenial; urgency=high 71 | 72 | * Fixed issue where `frame_width` and `frame_height` would be completely ignored (thanks @janecz-n!) 73 | * Fixed security problem with remote session authentication (thanks @cccaballero!) 74 | 75 | -- boltgolt Mon, 24 Sep 2018 17:49:07 +0100 76 | 77 | howdy (2.3.0) xenial; urgency=medium 78 | 79 | * Added a config option to set the frame height and width (thanks @wzrdtales!) 80 | * Rewrote the code that fetches the non-root username (thanks @dmig!) 81 | * Changed the config command so it uses the default editor (thanks @stellarpower and @dmig!) 82 | * Fixed issue where a "y" could be interpreted as a no (thanks @ramkrishna757575!) 83 | * Fixed division by zeno (thanks @stellarpower!) 84 | 85 | -- boltgolt Thu, 28 Jun 2018 14:59:52 +0100 86 | 87 | howdy (2.2.2) xenial; urgency=medium 88 | 89 | * Fixed fetching of wrong config section (thanks @halcyoncheng and @arifeinberg!) 90 | 91 | -- boltgolt Fri, 11 May 2018 10:43:03 +0200 92 | 93 | howdy (2.2.1) xenial; urgency=medium 94 | 95 | * Added mechanism to keep config files between updates 96 | * Added force_mjpeg option to fix YUYV image issues (thanks @arifeinberg!) 97 | * Revamped the bash autocompletion script 98 | * Fixed timeout never being reached in certain scenarios (thanks @Tkopic001!) 99 | * Fixed issue where BGR to RGB frame conversion caused a crash (thanks @Jerezano!) 100 | 101 | -- boltgolt Thu, 10 May 2018 15:14:03 +0200 102 | 103 | howdy (2.1.0) xenial; urgency=medium 104 | 105 | * First complete PPA release 106 | * Reworked CLI 107 | 108 | -- boltgolt Fri, 13 Apr 2018 22:22:27 +0200 109 | 110 | howdy (2.0.0-alpha+3) xenial; urgency=medium 111 | 112 | * Fixed issue where dlib dependency failed to install on some installations 113 | * Added preinst script for camera detection 114 | 115 | -- boltgolt Thu, 12 Apr 2018 21:42:42 +0000 116 | 117 | howdy (2.0.0-alpha+2) xenial; urgency=medium 118 | 119 | * Fixed build dependency issue 120 | 121 | -- boltgolt Sat, 07 Apr 2018 21:30:48 +0200 122 | 123 | howdy (2.0.0-alpha+1) xenial; urgency=low 124 | 125 | * Initial packaged release. 126 | 127 | -- boltgolt Wed, 04 Apr 2018 18:13:15 +0200 128 | -------------------------------------------------------------------------------- /howdy/debian/compat: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /howdy/debian/control: -------------------------------------------------------------------------------- 1 | Source: howdy 2 | Section: misc 3 | Priority: optional 4 | Standards-Version: 3.9.7 5 | Build-Depends: devscripts, git, dh-make, debhelper, fakeroot, python3, python3-pip, python3-setuptools, python3-wheel, ninja-build, meson, libpam0g-dev, libboost-all-dev, pkg-config, libevdev-dev, libinih-dev 6 | Maintainer: boltgolt 7 | Vcs-Git: https://github.com/boltgolt/howdy 8 | 9 | Package: howdy 10 | Homepage: https://github.com/boltgolt/howdy 11 | Architecture: amd64 12 | Depends: ${misc:Depends}, libc6, libgcc-s1, libpam0g, libstdc++6, curl | wget, python3, python3-pip, python3-dev, python3-setuptools, python3-numpy, python-opencv | python3-opencv, libopencv-dev, cmake, libinih-dev 13 | Recommends: libatlas-base-dev | libopenblas-dev | liblapack-dev, howdy-gtk, v4l-utils 14 | Suggests: nvidia-cuda-dev (>= 7.5) 15 | Description: Howdy: Windows Hello style authentication for Linux. 16 | Use your built-in IR emitters and camera in combination with face recognition 17 | to prove who you are. 18 | -------------------------------------------------------------------------------- /howdy/debian/copyright: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 boltgolt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /howdy/debian/howdy.lintian-overrides: -------------------------------------------------------------------------------- 1 | # W: Don't require ugly linebreaks in last 5 chars 2 | howdy: debian-changelog-line-too-long 3 | 4 | # E: Allows the name Howdy to show up in Ubuntu updater 5 | howdy: description-starts-with-package-name 6 | # E: Allows python for installation scripts 7 | howdy: unknown-control-interpreter 8 | -------------------------------------------------------------------------------- /howdy/debian/howdy.manpages: -------------------------------------------------------------------------------- 1 | howdy.1 2 | -------------------------------------------------------------------------------- /howdy/debian/install: -------------------------------------------------------------------------------- 1 | src/cli/. lib/security/howdy/cli 2 | src/locales/. lib/security/howdy/locales 3 | src/recorders/. lib/security/howdy/recorders 4 | src/rubberstamps/. lib/security/howdy/rubberstamps 5 | src/cli.py lib/security/howdy 6 | src/compare.py lib/security/howdy 7 | src/i18n.py lib/security/howdy 8 | src/logo.png lib/security/howdy 9 | src/snapshot.py lib/security/howdy 10 | 11 | build/pam_howdy.so lib/security/howdy 12 | 13 | src/dlib-data/. etc/howdy/dlib-data 14 | src/config.ini etc/howdy 15 | 16 | src/autocomplete/. usr/share/bash-completion/completions 17 | src/pam-config/. /usr/share/pam-configs 18 | -------------------------------------------------------------------------------- /howdy/debian/postinst: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # Installation script to install howdy 3 | # Executed after primary apt install 4 | 5 | # Import required modules 6 | import fileinput 7 | import subprocess 8 | import sys 9 | import os 10 | import re 11 | import tarfile 12 | from shutil import rmtree, which 13 | 14 | # Don't run unless we need to configure the install 15 | # Will also happen on upgrade but we will catch that later on 16 | if "configure" not in sys.argv: 17 | sys.exit(0) 18 | 19 | 20 | def log(text): 21 | """Print a nicely formatted line to stdout""" 22 | print("\n>>> " + col(1) + text + col(0) + "\n") 23 | 24 | 25 | def handleStatus(status): 26 | """Abort if a command fails""" 27 | if (status != 0): 28 | print(col(3) + "Error while running last command" + col(0)) 29 | sys.exit(1) 30 | 31 | 32 | def col(id): 33 | """Add color escape sequences""" 34 | if id == 1: return "\033[32m" 35 | if id == 2: return "\033[33m" 36 | if id == 3: return "\033[31m" 37 | return "\033[0m" 38 | 39 | 40 | # Create shorthand for subprocess creation 41 | sc = subprocess.call 42 | 43 | # If the package is being upgraded 44 | if "upgrade" in sys.argv: 45 | # If preinst has made a config backup 46 | if os.path.exists("/tmp/howdy_config_backup_v" + sys.argv[2] + ".ini"): 47 | # Get the config parser 48 | import configparser 49 | 50 | # Load th old and new config files 51 | oldConf = configparser.ConfigParser() 52 | oldConf.read("/tmp/howdy_config_backup_v" + sys.argv[2] + ".ini") 53 | newConf = configparser.ConfigParser() 54 | newConf.read("/etc/howdy/config.ini") 55 | 56 | # Go through every setting in the old config and apply it to the new file 57 | for section in oldConf.sections(): 58 | for (key, value) in oldConf.items(section): 59 | 60 | # MIGRATION 2.3.1 -> 2.4.0 61 | # If config is still using the old device_id parameter, convert it to a path 62 | if key == "device_id": 63 | key = "device_path" 64 | value = "/dev/video" + value 65 | 66 | # MIGRATION 2.4.0 -> 2.5.0 67 | # Finally correct typo in "timout" config value 68 | if key == "timout": 69 | key = "timeout" 70 | 71 | # MIGRATION 2.5.0 -> 2.5.1 72 | # Remove unsafe automatic dismissal of lock screen 73 | if key == "dismiss_lockscreen": 74 | if value == "true": 75 | print("DEPRECATION: Config value dismiss_lockscreen is no longer supported because of login loop issues.") 76 | continue 77 | 78 | # MIGRATION 2.6.1 -> 3.0.0 79 | # Fix capture being enabled by default 80 | if key == "capture_failed" or key == "capture_successful": 81 | if value == "true": 82 | print("NOTICE: Howdy login image captures have been disabled by default, change the config to enable them again") 83 | value = "false" 84 | 85 | # MIGRATION 2.6.1 -> 3.0.0 86 | # Rename config options so they don't do the opposite of what is commonly expected 87 | if key == "ignore_ssh": 88 | key = "abort_if_ssh" 89 | if key == "ignore_closed_lid": 90 | key = "abort_if_lid_closed" 91 | if key == "capture_failed": 92 | key = "save_failed" 93 | if key == "capture_successful": 94 | key = "save_successful" 95 | 96 | try: 97 | newConf.set(section, key, value) 98 | # Add a new section where needed 99 | except configparser.NoSectionError: 100 | newConf.add_section(section) 101 | newConf.set(section, key, value) 102 | 103 | # Write it all to file 104 | with open("/etc/howdy/config.ini", "w") as configfile: 105 | newConf.write(configfile) 106 | 107 | sys.exit(0) 108 | 109 | log("Downloading dlib") 110 | 111 | dlib_archive = "/tmp/v19.16.tar.gz" 112 | loader = which("wget") 113 | LOADER_CMD = None 114 | 115 | # If wget is installed, use that as the downloader 116 | if loader: 117 | LOADER_CMD = [loader, "--tries", "5", "--output-document"] 118 | # Otherwise, fall back on curl 119 | else: 120 | loader = which("curl") 121 | LOADER_CMD = [loader, "--retry", "5", "--location", "--output"] 122 | 123 | # Assemble and execute the download command 124 | cmd = LOADER_CMD + [dlib_archive, "https://github.com/davisking/dlib/archive/v19.16.tar.gz"] 125 | handleStatus(sc(cmd)) 126 | 127 | # The folder containing the dlib source 128 | DLIB_DIR = None 129 | # A regex of all files to ignore while unpacking the archive 130 | excludes = re.compile( 131 | "davisking-dlib-\w+/(dlib/(http_client|java|matlab|test/)|" 132 | "(docs|examples|python_examples)|" 133 | "tools/(archive|convert_dlib_nets_to_caffe|htmlify|imglab|python/test|visual_studio_natvis))" 134 | ) 135 | 136 | # Open the archive 137 | with tarfile.open(dlib_archive) as tf: 138 | for item in tf: 139 | # Set the destenation dir if unset 140 | if not DLIB_DIR: 141 | DLIB_DIR = "/tmp/" + item.name 142 | 143 | # extract only files sufficient for building 144 | if not excludes.match(item.name): 145 | tf.extract(item, "/tmp") 146 | 147 | # Delete the downloaded archive 148 | os.unlink(dlib_archive) 149 | 150 | log("Building dlib") 151 | 152 | cmd = ["sudo", "python3", "setup.py", "install"] 153 | cuda_used = False 154 | 155 | # Compile and link dlib 156 | try: 157 | sp = subprocess.Popen(cmd, cwd=DLIB_DIR, stdout=subprocess.PIPE) 158 | except subprocess.CalledProcessError: 159 | print("Error while building dlib") 160 | raise 161 | 162 | # Go through each line from stdout 163 | while sp.poll() is None: 164 | line = sp.stdout.readline().decode("utf-8") 165 | 166 | if "DLIB WILL USE CUDA" in line: 167 | cuda_used = True 168 | 169 | print(line, end="") 170 | 171 | log("Cleaning up dlib") 172 | 173 | # Remove the no longer needed git clone 174 | del sp 175 | rmtree(DLIB_DIR) 176 | 177 | print("Temporary dlib files removed") 178 | 179 | log("Configuring howdy") 180 | 181 | # Manually change the camera id to the one picked 182 | for line in fileinput.input(["/etc/howdy/config.ini"], inplace=1): 183 | line = line.replace("use_cnn = false", "use_cnn = " + str(cuda_used).lower()) 184 | print(line, end="") 185 | 186 | print("Camera ID saved") 187 | 188 | # Secure the howdy folder 189 | handleStatus(sc(["chmod 755 -R /lib/security/howdy/"], shell=True)) 190 | handleStatus(sc(["chmod 755 -R /etc/howdy/"], shell=True)) 191 | 192 | # Allow anyone to execute the python CLI 193 | os.chmod("/lib/security/howdy", 0o755) 194 | os.chmod("/lib/security/howdy/cli.py", 0o755) 195 | handleStatus(sc(["chmod 755 -R /lib/security/howdy/cli"], shell=True)) 196 | print("Permissions set") 197 | 198 | # Make the CLI executable as howdy 199 | os.symlink("/lib/security/howdy/cli.py", "/usr/local/bin/howdy") 200 | os.chmod("/usr/local/bin/howdy", 0o755) 201 | print("Howdy command installed") 202 | 203 | log("Adding howdy as PAM module") 204 | 205 | # Activate the pam-config file 206 | handleStatus(subprocess.call(["pam-auth-update --package"], shell=True)) 207 | 208 | # Sign off 209 | print("Installation complete.") 210 | -------------------------------------------------------------------------------- /howdy/debian/preinst: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # Used to check cameras before committing to install 3 | # Executed before primary apt install of files 4 | 5 | import subprocess 6 | import sys 7 | import os 8 | 9 | # Backup the config file if we're upgrading 10 | if "upgrade" in sys.argv: 11 | # Try to copy the config file as a backup 12 | try: 13 | # Try to copy the new location first 14 | if os.path.exists("/etc/howdy/config.ini"): 15 | subprocess.call(["cp /etc/howdy/config.ini /tmp/howdy_config_backup_v" + sys.argv[2] + ".ini"], shell=True) 16 | # If that does not exist, try copying the old location 17 | else: 18 | subprocess.call(["cp /lib/security/howdy/config.ini /tmp/howdy_config_backup_v" + sys.argv[2] + ".ini"], shell=True) 19 | 20 | # Let the user know so he knows where to look on a failed install 21 | print("Backup of Howdy config file created in /tmp/howdy_config_backup_v" + sys.argv[2] + ".ini") 22 | except subprocess.CalledProcessError: 23 | print("Could not make an backup of old Howdy config file") 24 | 25 | # Don't continue setup when we're just upgrading 26 | sys.exit(0) 27 | 28 | # Don't run if we're not trying to install fresh 29 | if "install" not in sys.argv: 30 | sys.exit(0) 31 | -------------------------------------------------------------------------------- /howdy/debian/prerm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # Executed on deinstallation 3 | # Completely remove howdy from the system 4 | 5 | # Import required modules 6 | import subprocess 7 | import sys 8 | import os 9 | from shutil import rmtree 10 | 11 | # Only run when we actually want to remove 12 | if "remove" not in sys.argv and "purge" not in sys.argv: 13 | sys.exit(0) 14 | 15 | # Don't try running this if it's already gone 16 | if not os.path.exists("/lib/security/howdy/cli"): 17 | sys.exit(0) 18 | 19 | # Remove files and symlinks 20 | try: 21 | os.unlink("/usr/local/bin/howdy") 22 | except Exception: 23 | print("Can't remove executable") 24 | try: 25 | os.unlink("/usr/share/bash-completion/completions/howdy") 26 | except Exception: 27 | print("Can't remove autocompletion script") 28 | 29 | # Refresh and remove howdy from pam-config 30 | try: 31 | subprocess.call(["pam-auth-update --package"], shell=True) 32 | subprocess.call(["rm /usr/share/pam-configs/howdy"], shell=True) 33 | subprocess.call(["pam-auth-update --package"], shell=True) 34 | except Exception: 35 | print("Can't remove pam module") 36 | 37 | # Remove full installation folder, just to be sure 38 | try: 39 | rmtree("/lib/security/howdy") 40 | except Exception: 41 | # This error is normal 42 | pass 43 | 44 | # Remove dlib 45 | subprocess.call(["pip3", "uninstall", "dlib", "-y", "--no-cache-dir"]) 46 | -------------------------------------------------------------------------------- /howdy/debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | DH_VERBOSE = 1 3 | 4 | DPKG_EXPORT_BUILDFLAGS = 1 5 | include /usr/share/dpkg/default.mk 6 | 7 | %: 8 | dh $@ 9 | 10 | build: 11 | # Create build dir 12 | meson setup -Dinih:with_INIReader=true build src/pam 13 | # Compile shared object 14 | ninja -C build 15 | 16 | clean: 17 | # Delete mason build directory 18 | rm -rf ./build 19 | # Force remove temp debian build directory 20 | rm -rf ./debian/howdy 21 | # Make sure subprojects get pulled locally 22 | meson subprojects download --sourcedir src/pam 23 | -------------------------------------------------------------------------------- /howdy/debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /howdy/debian/source/options: -------------------------------------------------------------------------------- 1 | tar-ignore = ".git" 2 | tar-ignore = ".gitignore" 3 | tar-ignore = ".github" 4 | tar-ignore = "models" 5 | tar-ignore = "snapshots" 6 | tar-ignore = "tests" 7 | tar-ignore = "README.md" 8 | tar-ignore = ".travis.yml" 9 | tar-ignore = "fedora" 10 | tar-ignore = "opensuse" 11 | tar-ignore = "archlinux" 12 | tar-ignore = "build" 13 | tar-ignore = "__pycache__" 14 | tar-ignore = "*.dat" 15 | -------------------------------------------------------------------------------- /howdy/howdy.1: -------------------------------------------------------------------------------- 1 | .\" Please adjust this date whenever revising the manpage. 2 | .TH HOWDY 1 "April 9, 2018" "Howdy help" "User Commands" 3 | .SH NAME 4 | howdy \- Windows Hello style authentication for Linux 5 | .SH DESCRIPTION 6 | Howdy IR face recognition implements a PAM module to use your face as a authentication method. 7 | .SS "Usage:" 8 | .IP 9 | howdy [\-U USER] [\-y] [\-h] command [argument] 10 | .SS "Commands:" 11 | .TP 12 | add 13 | Add a new face model for an user. 14 | .TP 15 | clear 16 | Remove all face models for an user. 17 | .TP 18 | config 19 | Open the config file in gedit. 20 | .TP 21 | disable 22 | Disable or enable howdy. 23 | .TP 24 | list 25 | List all saved face models for an user. 26 | .TP 27 | remove 28 | Remove a specific model for an user. 29 | .TP 30 | clear 31 | Remove all face models for an user. 32 | .TP 33 | test 34 | Test the camera and recognition methods. 35 | .SS "Optional arguments:" 36 | .TP 37 | \fB\-U\fR USER, \fB\-\-user\fR USER 38 | Set the user account to use. 39 | .TP 40 | \fB\-y\fR 41 | Skip all questions. 42 | .TP 43 | \fB\-h\fR, \fB\-\-help\fR 44 | Show this help message and exit. 45 | .PP 46 | .SH AUTHOR 47 | Howdy was written by boltgolt. For more information visit https://github.com/boltgolt/howdy 48 | -------------------------------------------------------------------------------- /howdy/meson.build: -------------------------------------------------------------------------------- 1 | subdir('src') -------------------------------------------------------------------------------- /howdy/src/autocomplete/howdy.in: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Autocomplete file run in bash 3 | # Will sugest arguments on tab 4 | 5 | _howdy() { 6 | local cur prev opts 7 | local config_path="@config_path@" 8 | COMPREPLY=() 9 | # The argument typed so far 10 | cur="${COMP_WORDS[COMP_CWORD]}" 11 | # The previous argument 12 | prev="${COMP_WORDS[COMP_CWORD-1]}" 13 | 14 | # Go though all cases we support 15 | case "${prev}" in 16 | # After the main command, show the commands 17 | "howdy") 18 | opts="add clear config disable list remove clear snapshot test version" 19 | COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) 20 | return 0 21 | ;; 22 | # For disable, grab the current "disabled" config option and give the reverse 23 | "disable") 24 | local status=$(cut -d'=' -f2 <<< $(cat $config_path | grep 'disabled =') | xargs echo -n) 25 | 26 | [ "$status" == "false" ] && COMPREPLY="true" || COMPREPLY="false" 27 | return 0 28 | ;; 29 | # List the users available 30 | "-U") 31 | COMPREPLY=( $(compgen -u -- ${cur}) ) 32 | return 0 33 | ;; 34 | "--user") 35 | COMPREPLY=( $(compgen -u -- ${cur}) ) 36 | return 0 37 | ;; 38 | *) 39 | ;; 40 | esac 41 | 42 | # Nothing matched, so return nothing 43 | return 0 44 | } 45 | 46 | # Register the autocomplete function 47 | complete -F _howdy howdy 48 | -------------------------------------------------------------------------------- /howdy/src/bin/howdy.in: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | @python_path@ "@script_path@" "$@" -------------------------------------------------------------------------------- /howdy/src/cli.py: -------------------------------------------------------------------------------- 1 | # CLI directly called by running the howdy command 2 | 3 | # Import required modules 4 | import sys 5 | import os 6 | import pwd 7 | import getpass 8 | import argparse 9 | import builtins 10 | 11 | from i18n import _ 12 | 13 | # Try to get the original username (not "root") from shell 14 | sudo_user = os.environ.get("SUDO_USER") 15 | doas_user = os.environ.get("DOAS_USER") 16 | pkexec_uid = os.environ.get("PKEXEC_UID") 17 | pkexec_user = pwd.getpwuid(int(pkexec_uid))[0] if pkexec_uid else "" 18 | env_user = getpass.getuser() 19 | user = next((u for u in [sudo_user, doas_user, pkexec_user, env_user] if u), "") 20 | 21 | # If that fails, error out 22 | if user == "": 23 | print(_("Could not determine user, please use the --user flag")) 24 | sys.exit(1) 25 | 26 | # Basic command setup 27 | parser = argparse.ArgumentParser( 28 | description=_("Command line interface for Howdy face authentication."), 29 | formatter_class=argparse.RawDescriptionHelpFormatter, 30 | add_help=False, 31 | prog="howdy", 32 | usage="howdy [-U USER] [--plain] [-h] [-y] {command} [{arguments}...]".format(command=_("command"), arguments=_("arguments")), 33 | epilog=_("For support please visit\nhttps://github.com/boltgolt/howdy")) 34 | 35 | # Add an argument for the command 36 | parser.add_argument( 37 | "command", 38 | help=_("The command option to execute, can be one of the following: add, clear, config, disable, list, remove, snapshot, set, test or version."), 39 | metavar="command", 40 | choices=["add", "clear", "config", "disable", "list", "remove", "set", "snapshot", "test", "version"]) 41 | 42 | # Add an argument for the extra arguments of disable and remove 43 | parser.add_argument( 44 | "arguments", 45 | help=_("Optional arguments for the add, disable, remove and set commands."), 46 | nargs="*") 47 | 48 | # Add the user flag 49 | parser.add_argument( 50 | "-U", "--user", 51 | default=user, 52 | help=_("Set the user account to use.")) 53 | 54 | # Add the -y flag 55 | parser.add_argument( 56 | "-y", 57 | help=_("Skip all questions."), 58 | action="store_true") 59 | 60 | # Add the --plain flag 61 | parser.add_argument( 62 | "--plain", 63 | help=_("Print machine-friendly output."), 64 | action="store_true") 65 | 66 | # Overwrite the default help message so we can use a uppercase S 67 | parser.add_argument( 68 | "-h", "--help", 69 | action="help", 70 | default=argparse.SUPPRESS, 71 | help=_("Show this help message and exit.")) 72 | 73 | # If we only have 1 argument we print the help text 74 | if len(sys.argv) < 2: 75 | print(_("current active user: ") + user + "\n") 76 | parser.print_help() 77 | sys.exit(0) 78 | 79 | # Parse all arguments above 80 | args = parser.parse_args() 81 | 82 | # Save the args and user as builtins which can be accessed by the imports 83 | builtins.howdy_args = args 84 | builtins.howdy_user = args.user 85 | 86 | # Check if we have rootish rights 87 | # This is this far down the file so running the command for help is always possible 88 | if os.geteuid() != 0: 89 | print(_("Please run this command as root:\n")) 90 | print("\tsudo howdy " + " ".join(sys.argv[1:])) 91 | sys.exit(1) 92 | 93 | # Beyond this point the user can't change anymore, if we still have root as user we need to abort 94 | if args.user == "root": 95 | print(_("Can't run howdy commands as root, please run this command with the --user flag")) 96 | sys.exit(1) 97 | 98 | # Execute the right command 99 | if args.command == "add": 100 | import cli.add 101 | elif args.command == "clear": 102 | import cli.clear 103 | elif args.command == "config": 104 | import cli.config 105 | elif args.command == "disable": 106 | import cli.disable 107 | elif args.command == "list": 108 | import cli.list 109 | elif args.command == "remove": 110 | import cli.remove 111 | elif args.command == "set": 112 | import cli.set 113 | elif args.command == "snapshot": 114 | import cli.snap 115 | elif args.command == "test": 116 | import cli.test 117 | else: 118 | print("Howdy 3.0.0 BETA") 119 | -------------------------------------------------------------------------------- /howdy/src/cli/__init__.py: -------------------------------------------------------------------------------- 1 | # Marks this folder as importable 2 | -------------------------------------------------------------------------------- /howdy/src/cli/add.py: -------------------------------------------------------------------------------- 1 | # Save the face of the user in encoded form 2 | 3 | # Import required modules 4 | import time 5 | import os 6 | import sys 7 | import json 8 | import configparser 9 | import builtins 10 | import numpy as np 11 | import paths_factory 12 | 13 | from recorders.video_capture import VideoCapture 14 | from i18n import _ 15 | 16 | # Try to import dlib and give a nice error if we can't 17 | # Add should be the first point where import issues show up 18 | try: 19 | import dlib 20 | except ImportError as err: 21 | print(err) 22 | 23 | print(_("\nCan't import the dlib module, check the output of")) 24 | print("pip3 show dlib") 25 | sys.exit(1) 26 | 27 | # OpenCV needs to be imported after dlib 28 | import cv2 29 | 30 | # Test if at lest 1 of the data files is there and abort if it's not 31 | if not os.path.isfile(paths_factory.shape_predictor_5_face_landmarks_path()): 32 | print(_("Data files have not been downloaded, please run the following commands:")) 33 | print("\n\tcd " + paths_factory.dlib_data_dir_path()) 34 | print("\tsudo ./install.sh\n") 35 | sys.exit(1) 36 | 37 | # Read config from disk 38 | config = configparser.ConfigParser() 39 | config.read(paths_factory.config_file_path()) 40 | 41 | use_cnn = config.getboolean("core", "use_cnn", fallback=False) 42 | if use_cnn: 43 | face_detector = dlib.cnn_face_detection_model_v1(paths_factory.mmod_human_face_detector_path()) 44 | else: 45 | face_detector = dlib.get_frontal_face_detector() 46 | 47 | pose_predictor = dlib.shape_predictor(paths_factory.shape_predictor_5_face_landmarks_path()) 48 | face_encoder = dlib.face_recognition_model_v1(paths_factory.dlib_face_recognition_resnet_model_v1_path()) 49 | 50 | user = builtins.howdy_user 51 | # The permanent file to store the encoded model in 52 | enc_file = paths_factory.user_model_path(user) 53 | # Known encodings 54 | encodings = [] 55 | 56 | # Make the ./models folder if it doesn't already exist 57 | if not os.path.exists(paths_factory.user_models_dir_path()): 58 | print(_("No face model folder found, creating one")) 59 | os.makedirs(paths_factory.user_models_dir_path()) 60 | 61 | # To try read a premade encodings file if it exists 62 | try: 63 | encodings = json.load(open(enc_file)) 64 | except FileNotFoundError: 65 | encodings = [] 66 | 67 | # Print a warning if too many encodings are being added 68 | if len(encodings) > 3: 69 | print(_("NOTICE: Each additional model slows down the face recognition engine slightly")) 70 | print(_("Press Ctrl+C to cancel\n")) 71 | 72 | # Make clear what we are doing if not human 73 | if not builtins.howdy_args.plain: 74 | print(_("Adding face model for the user ") + user) 75 | 76 | # Set the default label 77 | label = "Initial model" 78 | 79 | # some id's can be skipped, but the last id is always the maximum 80 | next_id = encodings[-1]["id"] + 1 if encodings else 0 81 | 82 | # Get the label from the cli arguments if provided 83 | if builtins.howdy_args.arguments: 84 | label = builtins.howdy_args.arguments[0] 85 | 86 | # Or set the default label 87 | else: 88 | label = _("Model #") + str(next_id) 89 | 90 | # Keep de default name if we can't ask questions 91 | if builtins.howdy_args.y: 92 | print(_('Using default label "%s" because of -y flag') % (label, )) 93 | else: 94 | # Ask the user for a custom label 95 | label_in = input(_("Enter a label for this new model [{}]: ").format(label)) 96 | 97 | # Set the custom label (if any) and limit it to 24 characters 98 | if label_in != "": 99 | label = label_in[:24] 100 | 101 | # Remove illegal characters 102 | if "," in label: 103 | print(_("NOTICE: Removing illegal character \",\" from model name")) 104 | label = label.replace(",", "") 105 | 106 | # Prepare the metadata for insertion 107 | insert_model = { 108 | "time": int(time.time()), 109 | "label": label, 110 | "id": next_id, 111 | "data": [] 112 | } 113 | 114 | # Set up video_capture 115 | video_capture = VideoCapture(config) 116 | 117 | print(_("\nPlease look straight into the camera")) 118 | 119 | # Give the user time to read 120 | time.sleep(2) 121 | 122 | # Will contain found face encodings 123 | enc = [] 124 | # Count the number of read frames 125 | frames = 0 126 | # Count the number of illuminated read frames 127 | valid_frames = 0 128 | # Count the number of illuminated frames that 129 | # were rejected for being too dark 130 | dark_tries = 0 131 | # Track the running darkness total 132 | dark_running_total = 0 133 | face_locations = None 134 | 135 | dark_threshold = config.getfloat("video", "dark_threshold", fallback=60) 136 | 137 | clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) 138 | 139 | # Loop through frames till we hit a timeout 140 | while frames < 60: 141 | frames += 1 142 | # Grab a single frame of video 143 | frame, gsframe = video_capture.read_frame() 144 | gsframe = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) 145 | gsframe = clahe.apply(gsframe) 146 | 147 | # Create a histogram of the image with 8 values 148 | hist = cv2.calcHist([gsframe], [0], None, [8], [0, 256]) 149 | # All values combined for percentage calculation 150 | hist_total = np.sum(hist) 151 | 152 | # Calculate frame darkness 153 | darkness = (hist[0] / hist_total * 100) 154 | 155 | # If the image is fully black due to a bad camera read, 156 | # skip to the next frame 157 | if (hist_total == 0) or (darkness == 100): 158 | continue 159 | 160 | # Include this frame in calculating our average session brightness 161 | dark_running_total += darkness 162 | valid_frames += 1 163 | 164 | # If the image exceeds darkness threshold due to subject distance, 165 | # skip to the next frame 166 | if (darkness > dark_threshold): 167 | dark_tries += 1 168 | continue 169 | 170 | # Get all faces from that frame as encodings 171 | face_locations = face_detector(gsframe, 1) 172 | 173 | # If we've found at least one, we can continue 174 | if face_locations: 175 | break 176 | 177 | video_capture.release() 178 | 179 | # If we've found no faces, try to determine why 180 | if not face_locations: 181 | if valid_frames == 0: 182 | print(_("Camera saw only black frames - is IR emitter working?")) 183 | elif valid_frames == dark_tries: 184 | print(_("All frames were too dark, please check dark_threshold in config")) 185 | print(_("Average darkness: {avg}, Threshold: {threshold}").format(avg=str(dark_running_total / valid_frames), threshold=str(dark_threshold))) 186 | else: 187 | print(_("No face detected, aborting")) 188 | sys.exit(1) 189 | 190 | # If more than 1 faces are detected we can't know which one belongs to the user 191 | elif len(face_locations) > 1: 192 | print(_("Multiple faces detected, aborting")) 193 | sys.exit(1) 194 | 195 | face_location = face_locations[0] 196 | if use_cnn: 197 | face_location = face_location.rect 198 | 199 | # Get the encodings in the frame 200 | face_landmark = pose_predictor(frame, face_location) 201 | face_encoding = np.array(face_encoder.compute_face_descriptor(frame, face_landmark, 1)) 202 | 203 | insert_model["data"].append(face_encoding.tolist()) 204 | 205 | # Insert full object into the list 206 | encodings.append(insert_model) 207 | 208 | # Save the new encodings to disk 209 | with open(enc_file, "w") as datafile: 210 | json.dump(encodings, datafile) 211 | 212 | # Give let the user know how it went 213 | print(_("""\nScan complete 214 | Added a new model to """) + user) 215 | -------------------------------------------------------------------------------- /howdy/src/cli/clear.py: -------------------------------------------------------------------------------- 1 | # Clear all models by deleting the whole file 2 | 3 | # Import required modules 4 | import os 5 | import sys 6 | import builtins 7 | import paths_factory 8 | 9 | from i18n import _ 10 | 11 | # Get the passed user 12 | user = builtins.howdy_user 13 | 14 | # Check if the models folder is there 15 | if not os.path.exists(paths_factory.user_models_dir_path()): 16 | print(_("No models created yet, can't clear them if they don't exist")) 17 | sys.exit(1) 18 | 19 | # Check if the user has a models file to delete 20 | if not os.path.isfile(paths_factory.user_model_path(user)): 21 | print(_("{} has no models or they have been cleared already").format(user)) 22 | sys.exit(1) 23 | 24 | # Only ask the user if there's no -y flag 25 | if not builtins.howdy_args.y: 26 | # Double check with the user 27 | print(_("This will clear all models for ") + user) 28 | ans = input(_("Do you want to continue [y/N]: ")) 29 | 30 | # Abort if they don't answer y or Y 31 | if (ans.lower() != "y"): 32 | print(_('\nInterpreting as a "NO", aborting')) 33 | sys.exit(1) 34 | 35 | # Delete otherwise 36 | os.remove(paths_factory.user_model_path(user)) 37 | print(_("\nModels cleared")) 38 | -------------------------------------------------------------------------------- /howdy/src/cli/config.py: -------------------------------------------------------------------------------- 1 | # Open the config file in an editor 2 | 3 | # Import required modules 4 | import os 5 | import subprocess 6 | import paths_factory 7 | 8 | from i18n import _ 9 | 10 | # Let the user know what we're doing 11 | print(_("Opening config.ini in the default editor")) 12 | 13 | # Default to the nano editor 14 | editor = "/bin/nano" 15 | 16 | # Use the user preferred editor if available 17 | if "EDITOR" in os.environ: 18 | editor = os.environ["EDITOR"] 19 | elif os.path.isfile("/etc/alternatives/editor"): 20 | editor = "/etc/alternatives/editor" 21 | 22 | # Open the editor as a subprocess and fork it 23 | subprocess.call([editor, paths_factory.config_file_path()]) 24 | -------------------------------------------------------------------------------- /howdy/src/cli/disable.py: -------------------------------------------------------------------------------- 1 | # Set the disable flag 2 | 3 | # Import required modules 4 | import sys 5 | import os 6 | import builtins 7 | import fileinput 8 | import configparser 9 | import paths_factory 10 | 11 | from i18n import _ 12 | 13 | # Get the absolute filepath 14 | config_path = paths_factory.config_file_path() 15 | 16 | # Read config from disk 17 | config = configparser.ConfigParser() 18 | config.read(config_path) 19 | 20 | # Check if enough arguments have been passed 21 | if not builtins.howdy_args.arguments: 22 | print(_("Please add a 0 (enable) or a 1 (disable) as an argument")) 23 | sys.exit(1) 24 | 25 | # Get the cli argument 26 | argument = builtins.howdy_args.arguments[0] 27 | 28 | # Translate the argument to the right string 29 | if argument == "1" or argument.lower() == "true": 30 | out_value = "true" 31 | elif argument == "0" or argument.lower() == "false": 32 | out_value = "false" 33 | else: 34 | # Of it's not a 0 or a 1, it's invalid 35 | print(_("Please only use 0 (enable) or 1 (disable) as an argument")) 36 | sys.exit(1) 37 | 38 | # Don't do anything when the state is already the requested one 39 | if out_value == config.get("core", "disabled", fallback=True): 40 | print(_("The disable option has already been set to ") + out_value) 41 | sys.exit(1) 42 | 43 | # Loop though the config file and only replace the line containing the disable config 44 | for line in fileinput.input([config_path], inplace=1): 45 | print(line.replace("disabled = " + config.get("core", "disabled", fallback=True), "disabled = " + out_value), end="") 46 | 47 | # Print what we just did 48 | if out_value == "true": 49 | print(_("Howdy has been disabled")) 50 | else: 51 | print(_("Howdy has been enabled")) 52 | -------------------------------------------------------------------------------- /howdy/src/cli/list.py: -------------------------------------------------------------------------------- 1 | # List all models for a user 2 | 3 | # Import required modules 4 | import sys 5 | import os 6 | import json 7 | import time 8 | import builtins 9 | import paths_factory 10 | 11 | from i18n import _ 12 | 13 | user = builtins.howdy_user 14 | 15 | # Check if the models file has been created yet 16 | if not os.path.exists(paths_factory.user_models_dir_path()): 17 | print(_("Face models have not been initialized yet, please run:")) 18 | print("\n\tsudo howdy -U " + user + " add\n") 19 | sys.exit(1) 20 | 21 | # Path to the models file 22 | enc_file = paths_factory.user_model_path(user) 23 | 24 | # Try to load the models file and abort if the user does not have it yet 25 | try: 26 | encodings = json.load(open(enc_file)) 27 | except FileNotFoundError: 28 | if not builtins.howdy_args.plain: 29 | print(_("No face model known for the user {}, please run:").format(user)) 30 | print("\n\tsudo howdy -U " + user + " add\n") 31 | sys.exit(1) 32 | 33 | # Print a header if we're not in plain mode 34 | if not builtins.howdy_args.plain: 35 | print(_("Known face models for {}:").format(user)) 36 | print("\n\033[1;29m" + _("ID Date Label\033[0m")) 37 | 38 | # Loop through all encodings and print info about them 39 | for enc in encodings: 40 | # Start with the id 41 | print(str(enc["id"]), end="") 42 | 43 | # Add comma for machine reading 44 | if builtins.howdy_args.plain: 45 | print(",", end="") 46 | # Print padding spaces after the id for a nice layout 47 | else: 48 | print((4 - len(str(enc["id"]))) * " ", end="") 49 | 50 | # Format the time as ISO in the local timezone 51 | print(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(enc["time"])), end="") 52 | 53 | # Separate with commas again for machines, spaces otherwise 54 | print("," if builtins.howdy_args.plain else " ", end="") 55 | 56 | # End with the label 57 | print(enc["label"]) 58 | 59 | # Add a closing enter 60 | print() 61 | -------------------------------------------------------------------------------- /howdy/src/cli/remove.py: -------------------------------------------------------------------------------- 1 | # Remove a encoding from the models file 2 | 3 | # Import required modules 4 | import sys 5 | import os 6 | import json 7 | import builtins 8 | import paths_factory 9 | 10 | from i18n import _ 11 | 12 | user = builtins.howdy_user 13 | 14 | # Check if enough arguments have been passed 15 | if not builtins.howdy_args.arguments: 16 | print(_("Please add the ID of the model you want to remove as an argument")) 17 | print(_("For example:")) 18 | print("\n\thowdy remove 0\n") 19 | print(_("You can find the IDs by running:")) 20 | print("\n\thowdy list\n") 21 | sys.exit(1) 22 | 23 | # Check if the models file has been created yet 24 | if not os.path.exists(paths_factory.user_models_dir_path()): 25 | print(_("Face models have not been initialized yet, please run:")) 26 | print("\n\thowdy add\n") 27 | sys.exit(1) 28 | 29 | # Path to the models file 30 | enc_file = paths_factory.user_model_path(user) 31 | 32 | # Try to load the models file and abort if the user does not have it yet 33 | try: 34 | encodings = json.load(open(enc_file)) 35 | except FileNotFoundError: 36 | print(_("No face model known for the user {}, please run:").format(user)) 37 | print("\n\thowdy add\n") 38 | sys.exit(1) 39 | 40 | # Tracks if a encoding with that id has been found 41 | found = False 42 | 43 | # Get the ID from the cli arguments 44 | id = builtins.howdy_args.arguments[0] 45 | 46 | # Loop though all encodings and check if they match the argument 47 | for enc in encodings: 48 | if str(enc["id"]) == id: 49 | # Only ask the user if there's no -y flag 50 | if not builtins.howdy_args.y: 51 | # Double check with the user 52 | print(_('This will remove the model called "{label}" for {user}').format(label=enc["label"], user=user)) 53 | ans = input(_("Do you want to continue [y/N]: ")) 54 | 55 | # Abort if the answer isn't yes 56 | if (ans.lower() != "y"): 57 | print(_('\nInterpreting as a "NO", aborting')) 58 | sys.exit(1) 59 | 60 | # Add a padding empty line 61 | print() 62 | 63 | # Mark as found and print an enter 64 | found = True 65 | break 66 | 67 | # Abort if no matching id was found 68 | if not found: 69 | print(_("No model with ID {id} exists for {user}").format(id=id, user=user)) 70 | sys.exit(1) 71 | 72 | # Remove the entire file if this encoding is the only one 73 | if len(encodings) == 1: 74 | os.remove(paths_factory.user_model_path(user)) 75 | print(_("Removed last model, howdy disabled for user")) 76 | else: 77 | # A place holder to contain the encodings that will remain 78 | new_encodings = [] 79 | 80 | # Loop though all encodings and only add those that don't need to be removed 81 | for enc in encodings: 82 | if str(enc["id"]) != id: 83 | new_encodings.append(enc) 84 | 85 | # Save this new set to disk 86 | with open(enc_file, "w") as datafile: 87 | json.dump(new_encodings, datafile) 88 | 89 | print(_("Removed model {}").format(id)) 90 | -------------------------------------------------------------------------------- /howdy/src/cli/set.py: -------------------------------------------------------------------------------- 1 | # Set a config value 2 | 3 | # Import required modules 4 | import sys 5 | import os 6 | import builtins 7 | import fileinput 8 | import paths_factory 9 | 10 | from i18n import _ 11 | 12 | # Get the absolute filepath 13 | config_path = paths_factory.config_file_path() 14 | 15 | # Check if enough arguments have been passed 16 | if len(builtins.howdy_args.arguments) < 2: 17 | print(_("Please add a setting you would like to change and the value to set it to")) 18 | print(_("For example:")) 19 | print("\n\thowdy set certainty 3\n") 20 | sys.exit(1) 21 | 22 | # Get the name and value from the cli 23 | set_name = builtins.howdy_args.arguments[0] 24 | set_value = builtins.howdy_args.arguments[1] 25 | 26 | # Will be filled with the correctly config line to update 27 | found_line = "" 28 | 29 | # Loop through all lines in the config file 30 | for line in fileinput.input([config_path]): 31 | # Save the line if it starts with the requested config option 32 | if line.startswith(set_name + " "): 33 | found_line = line 34 | 35 | # If we don't have the line it is not in the config file 36 | if not found_line: 37 | print(_('Could not find a "{}" config option to set').format(set_name)) 38 | sys.exit(1) 39 | 40 | # Go through the file again and update the correct line 41 | for line in fileinput.input([config_path], inplace=1): 42 | print(line.replace(found_line, set_name + " = " + set_value + "\n"), end="") 43 | 44 | print(_("Config option updated")) 45 | -------------------------------------------------------------------------------- /howdy/src/cli/snap.py: -------------------------------------------------------------------------------- 1 | # Create a snapshot 2 | 3 | # Import required modules 4 | import os 5 | import configparser 6 | from datetime import timezone, datetime 7 | import snapshot 8 | import paths_factory 9 | from recorders.video_capture import VideoCapture 10 | 11 | from i18n import _ 12 | 13 | # Read the config 14 | config = configparser.ConfigParser() 15 | config.read(paths_factory.config_file_path()) 16 | 17 | # Start video capture 18 | video_capture = VideoCapture(config) 19 | 20 | # Read a frame to activate emitters 21 | video_capture.read_frame() 22 | 23 | # Read exposure and dark_thresholds from config to use in the main loop 24 | exposure = config.getint("video", "exposure", fallback=-1) 25 | dark_threshold = config.getfloat("video", "dark_threshold", fallback=60) 26 | 27 | # COllection of recorded frames 28 | frames = [] 29 | 30 | while True: 31 | # Grab a single frame of video 32 | frame, gsframe = video_capture.read_frame() 33 | 34 | # Add the frame to the list 35 | frames.append(frame) 36 | 37 | # Stop the loop if we have 4 frames 38 | if len(frames) >= 4: 39 | break 40 | 41 | # Generate a snapshot image from the frames 42 | file = snapshot.generate(frames, [ 43 | _("GENERATED SNAPSHOT"), 44 | _("Date: ") + datetime.now(timezone.utc).strftime("%Y/%m/%d %H:%M:%S UTC"), 45 | _("Dark threshold config: ") + str(config.getfloat("video", "dark_threshold", fallback=60.0)), 46 | _("Certainty config: ") + str(config.getfloat("video", "certainty", fallback=3.5)) 47 | ]) 48 | 49 | # Show the file location in console 50 | print(_("Generated snapshot saved as")) 51 | print(file) 52 | -------------------------------------------------------------------------------- /howdy/src/cli/test.py: -------------------------------------------------------------------------------- 1 | # Show a window with the video stream and testing information 2 | 3 | # Import required modules 4 | import configparser 5 | import builtins 6 | import os 7 | import json 8 | import sys 9 | import time 10 | import dlib 11 | import cv2 12 | import numpy as np 13 | import paths_factory 14 | 15 | from i18n import _ 16 | from recorders.video_capture import VideoCapture 17 | 18 | # Read config from disk 19 | config = configparser.ConfigParser() 20 | config.read(paths_factory.config_file_path()) 21 | 22 | if config.get("video", "recording_plugin", fallback="opencv") != "opencv": 23 | print(_("Howdy has been configured to use a recorder which doesn't support the test command yet, aborting")) 24 | sys.exit(12) 25 | 26 | video_capture = VideoCapture(config) 27 | 28 | # Read config values to use in the main loop 29 | video_certainty = config.getfloat("video", "certainty", fallback=3.5) / 10 30 | exposure = config.getint("video", "exposure", fallback=-1) 31 | dark_threshold = config.getfloat("video", "dark_threshold", fallback=60) 32 | 33 | # Let the user know what's up 34 | print(_(""" 35 | Opening a window with a test feed 36 | 37 | Press ctrl+C in this terminal to quit 38 | Click on the image to enable or disable slow mode 39 | """)) 40 | 41 | 42 | def mouse(event, x, y, flags, param): 43 | """Handle mouse events""" 44 | global slow_mode 45 | 46 | # Toggle slowmode on click 47 | if event == cv2.EVENT_LBUTTONDOWN: 48 | slow_mode = not slow_mode 49 | 50 | 51 | def print_text(line_number, text): 52 | """Print the status text by line number""" 53 | cv2.putText(overlay, text, (10, height - 10 - (10 * line_number)), cv2.FONT_HERSHEY_SIMPLEX, .3, (0, 255, 0), 0, cv2.LINE_AA) 54 | 55 | 56 | use_cnn = config.getboolean('core', 'use_cnn', fallback=False) 57 | 58 | if use_cnn: 59 | face_detector = dlib.cnn_face_detection_model_v1( 60 | paths_factory.mmod_human_face_detector_path() 61 | ) 62 | else: 63 | face_detector = dlib.get_frontal_face_detector() 64 | 65 | pose_predictor = dlib.shape_predictor(paths_factory.shape_predictor_5_face_landmarks_path()) 66 | face_encoder = dlib.face_recognition_model_v1(paths_factory.dlib_face_recognition_resnet_model_v1_path()) 67 | 68 | encodings = [] 69 | models = None 70 | 71 | try: 72 | user = builtins.howdy_user 73 | models = json.load(open(paths_factory.user_model_path(user))) 74 | 75 | for model in models: 76 | encodings += model["data"] 77 | except FileNotFoundError: 78 | pass 79 | 80 | clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) 81 | 82 | # Open the window and attach a a mouse listener 83 | cv2.namedWindow("Howdy Test") 84 | cv2.setMouseCallback("Howdy Test", mouse) 85 | 86 | # Enable a delay in the loop 87 | slow_mode = False 88 | # Count all frames ever 89 | total_frames = 0 90 | # Count all frames per second 91 | sec_frames = 0 92 | # Last secands FPS 93 | fps = 0 94 | # The current second we're counting 95 | sec = int(time.time()) 96 | # recognition time 97 | rec_tm = 0 98 | 99 | # Wrap everything in an keyboard interrupt handler 100 | try: 101 | while True: 102 | frame_tm = time.time() 103 | 104 | # Increment the frames 105 | total_frames += 1 106 | sec_frames += 1 107 | 108 | # Id we've entered a new second 109 | if sec != int(frame_tm): 110 | # Set the last seconds FPS 111 | fps = sec_frames 112 | 113 | # Set the new second and reset the counter 114 | sec = int(frame_tm) 115 | sec_frames = 0 116 | 117 | # Grab a single frame of video 118 | orig_frame, frame = video_capture.read_frame() 119 | 120 | frame = clahe.apply(frame) 121 | # Make a frame to put overlays in 122 | overlay = frame.copy() 123 | overlay = cv2.cvtColor(overlay, cv2.COLOR_GRAY2BGR) 124 | 125 | # Fetch the frame height and width 126 | height, width = frame.shape[:2] 127 | 128 | # Create a histogram of the image with 8 values 129 | hist = cv2.calcHist([frame], [0], None, [8], [0, 256]) 130 | # All values combined for percentage calculation 131 | hist_total = int(sum(hist)[0]) 132 | # Fill with the overall containing percentage 133 | hist_perc = [] 134 | 135 | # Loop though all values to calculate a percentage and add it to the overlay 136 | for index, value in enumerate(hist): 137 | value_perc = float(value[0]) / hist_total * 100 138 | hist_perc.append(value_perc) 139 | 140 | # Top left point, 10px margins 141 | p1 = (20 + (10 * index), 10) 142 | # Bottom right point makes the bar 10px thick, with an height of half the percentage 143 | p2 = (10 + (10 * index), int(value_perc / 2 + 10)) 144 | # Draw the bar in green 145 | cv2.rectangle(overlay, p1, p2, (0, 200, 0), thickness=cv2.FILLED) 146 | 147 | # Print the statis in the bottom left 148 | print_text(0, _("RESOLUTION: %dx%d") % (height, width)) 149 | print_text(1, _("FPS: %d") % (fps, )) 150 | print_text(2, _("FRAMES: %d") % (total_frames, )) 151 | print_text(3, _("RECOGNITION: %dms") % (round(rec_tm * 1000), )) 152 | 153 | # Show that slow mode is on, if it's on 154 | if slow_mode: 155 | cv2.putText(overlay, _("SLOW MODE"), (width - 66, height - 10), cv2.FONT_HERSHEY_SIMPLEX, .3, (0, 0, 255), 0, cv2.LINE_AA) 156 | 157 | # Ignore dark frames 158 | if hist_perc[0] > dark_threshold: 159 | # Show that this is an ignored frame in the top right 160 | cv2.putText(overlay, _("DARK FRAME"), (width - 68, 16), cv2.FONT_HERSHEY_SIMPLEX, .3, (0, 0, 255), 0, cv2.LINE_AA) 161 | else: 162 | # Show that this is an active frame 163 | cv2.putText(overlay, _("SCAN FRAME"), (width - 68, 16), cv2.FONT_HERSHEY_SIMPLEX, .3, (0, 255, 0), 0, cv2.LINE_AA) 164 | 165 | rec_tm = time.time() 166 | 167 | # Get the locations of all faces and their locations 168 | # Upsample it once 169 | face_locations = face_detector(frame, 1) 170 | rec_tm = time.time() - rec_tm 171 | 172 | # Loop though all faces and paint a circle around them 173 | for loc in face_locations: 174 | if use_cnn: 175 | loc = loc.rect 176 | 177 | # By default the circle around the face is red for no match 178 | color = (0, 0, 230) 179 | 180 | # Get the center X and Y from the rectangular points 181 | x = int((loc.right() - loc.left()) / 2) + loc.left() 182 | y = int((loc.bottom() - loc.top()) / 2) + loc.top() 183 | 184 | # Get the raduis from the with of the square 185 | r = (loc.right() - loc.left()) / 2 186 | # Add 20% padding 187 | r = int(r + (r * 0.2)) 188 | 189 | # If we have models defined for the current user 190 | if models: 191 | # Get the encoding of the face in the frame 192 | face_landmark = pose_predictor(orig_frame, loc) 193 | face_encoding = np.array(face_encoder.compute_face_descriptor(orig_frame, face_landmark, 1)) 194 | 195 | # Match this found face against a known face 196 | matches = np.linalg.norm(encodings - face_encoding, axis=1) 197 | 198 | # Get best match 199 | match_index = np.argmin(matches) 200 | match = matches[match_index] 201 | 202 | # If a model matches 203 | if 0 < match < video_certainty: 204 | # Turn the circle green 205 | color = (0, 230, 0) 206 | 207 | # Print the name of the model next to the circle 208 | circle_text = "{} (certainty: {})".format(models[match_index]["label"], round(match * 10, 3)) 209 | cv2.putText(overlay, circle_text, (int(x + r / 3), y - r), cv2.FONT_HERSHEY_SIMPLEX, .3, (0, 255, 0), 0, cv2.LINE_AA) 210 | # If no approved matches, show red text 211 | else: 212 | cv2.putText(overlay, "no match", (int(x + r / 3), y - r), cv2.FONT_HERSHEY_SIMPLEX, .3, (0, 0, 255), 0, cv2.LINE_AA) 213 | 214 | # Draw the Circle in green 215 | cv2.circle(overlay, (x, y), r, color, 2) 216 | 217 | # Add the overlay to the frame with some transparency 218 | alpha = 0.65 219 | frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) 220 | cv2.addWeighted(overlay, alpha, frame, 1 - alpha, 0, frame) 221 | 222 | # Show the image in a window 223 | cv2.imshow("Howdy Test", frame) 224 | 225 | # Quit on any keypress 226 | if cv2.waitKey(1) != -1: 227 | raise KeyboardInterrupt() 228 | 229 | frame_time = time.time() - frame_tm 230 | 231 | # Delay the frame if slowmode is on 232 | if slow_mode: 233 | time.sleep(max([.5 - frame_time, 0.0])) 234 | 235 | if exposure != -1: 236 | # For a strange reason on some cameras (e.g. Lenoxo X1E) 237 | # setting manual exposure works only after a couple frames 238 | # are captured and even after a delay it does not 239 | # always work. Setting exposure at every frame is 240 | # reliable though. 241 | video_capture.internal.set(cv2.CAP_PROP_AUTO_EXPOSURE, 1.0) # 1 = Manual 242 | video_capture.internal.set(cv2.CAP_PROP_EXPOSURE, float(exposure)) 243 | 244 | # On ctrl+C 245 | except KeyboardInterrupt: 246 | # Let the user know we're stopping 247 | print(_("\nClosing window")) 248 | 249 | # Release handle to the webcam 250 | cv2.destroyAllWindows() 251 | -------------------------------------------------------------------------------- /howdy/src/compare.py: -------------------------------------------------------------------------------- 1 | # Compare incoming video with known faces 2 | # Running in a local python instance to get around PATH issues 3 | 4 | # Import time so we can start timing asap 5 | import time 6 | 7 | # Start timing 8 | timings = { 9 | "st": time.time() 10 | } 11 | 12 | # Import required modules 13 | import sys 14 | import os 15 | import json 16 | import configparser 17 | import dlib 18 | import cv2 19 | from datetime import timezone, datetime 20 | import atexit 21 | import subprocess 22 | import snapshot 23 | import numpy as np 24 | import _thread as thread 25 | import paths_factory 26 | from recorders.video_capture import VideoCapture 27 | from i18n import _ 28 | 29 | def exit(code=None): 30 | """Exit while closing howdy-gtk properly""" 31 | global gtk_proc 32 | 33 | # Exit the auth ui process if there is one 34 | if "gtk_proc" in globals(): 35 | gtk_proc.terminate() 36 | 37 | # Exit compare 38 | if code is not None: 39 | sys.exit(code) 40 | 41 | 42 | def init_detector(lock): 43 | """Start face detector, encoder and predictor in a new thread""" 44 | global face_detector, pose_predictor, face_encoder 45 | 46 | # Test if at lest 1 of the data files is there and abort if it's not 47 | if not os.path.isfile(paths_factory.shape_predictor_5_face_landmarks_path()): 48 | print(_("Data files have not been downloaded, please run the following commands:")) 49 | print("\n\tcd " + paths_factory.dlib_data_dir_path()) 50 | print("\tsudo ./install.sh\n") 51 | lock.release() 52 | exit(1) 53 | 54 | # Use the CNN detector if enabled 55 | if use_cnn: 56 | face_detector = dlib.cnn_face_detection_model_v1(paths_factory.mmod_human_face_detector_path()) 57 | else: 58 | face_detector = dlib.get_frontal_face_detector() 59 | 60 | # Start the others regardless 61 | pose_predictor = dlib.shape_predictor(paths_factory.shape_predictor_5_face_landmarks_path()) 62 | face_encoder = dlib.face_recognition_model_v1(paths_factory.dlib_face_recognition_resnet_model_v1_path()) 63 | 64 | # Note the time it took to initialize detectors 65 | timings["ll"] = time.time() - timings["ll"] 66 | lock.release() 67 | 68 | 69 | def make_snapshot(type): 70 | """Generate snapshot after detection""" 71 | snapshot.generate(snapframes, [ 72 | type + _(" LOGIN"), 73 | _("Date: ") + datetime.now(timezone.utc).strftime("%Y/%m/%d %H:%M:%S UTC"), 74 | _("Scan time: ") + str(round(time.time() - timings["fr"], 2)) + "s", 75 | _("Frames: ") + str(frames) + " (" + str(round(frames / (time.time() - timings["fr"]), 2)) + "FPS)", 76 | _("Hostname: ") + os.uname().nodename, 77 | _("Best certainty value: ") + str(round(lowest_certainty * 10, 1)) 78 | ]) 79 | 80 | 81 | def send_to_ui(type, message): 82 | """Send message to the auth ui""" 83 | global gtk_proc 84 | 85 | # Only execute of the process started 86 | if "gtk_proc" in globals(): 87 | # Format message so the ui can parse it 88 | message = type + "=" + message + " \n" 89 | 90 | # Try to send the message to the auth ui, but it's okay if that fails 91 | try: 92 | gtk_proc.stdin.write(bytearray(message.encode("utf-8"))) 93 | gtk_proc.stdin.flush() 94 | except IOError: 95 | pass 96 | 97 | 98 | # Make sure we were given an username to test against 99 | if len(sys.argv) < 2: 100 | exit(12) 101 | 102 | # The username of the user being authenticated 103 | user = sys.argv[1] 104 | # The model file contents 105 | models = [] 106 | # Encoded face models 107 | encodings = [] 108 | # Amount of ignored 100% black frames 109 | black_tries = 0 110 | # Amount of ignored dark frames 111 | dark_tries = 0 112 | # Total amount of frames captured 113 | frames = 0 114 | # Captured frames for snapshot capture 115 | snapframes = [] 116 | # Tracks the lowest certainty value in the loop 117 | lowest_certainty = 10 118 | # Face recognition/detection instances 119 | face_detector = None 120 | pose_predictor = None 121 | face_encoder = None 122 | 123 | # Try to load the face model from the models folder 124 | try: 125 | models = json.load(open(paths_factory.user_model_path(user))) 126 | 127 | for model in models: 128 | encodings += model["data"] 129 | except FileNotFoundError: 130 | exit(10) 131 | 132 | # Check if the file contains a model 133 | if len(models) < 1: 134 | exit(10) 135 | 136 | # Read config from disk 137 | config = configparser.ConfigParser() 138 | config.read(paths_factory.config_file_path()) 139 | 140 | # Get all config values needed 141 | use_cnn = config.getboolean("core", "use_cnn", fallback=False) 142 | timeout = config.getint("video", "timeout", fallback=4) 143 | dark_threshold = config.getfloat("video", "dark_threshold", fallback=50.0) 144 | video_certainty = config.getfloat("video", "certainty", fallback=3.5) / 10 145 | end_report = config.getboolean("debug", "end_report", fallback=False) 146 | save_failed = config.getboolean("snapshots", "save_failed", fallback=False) 147 | save_successful = config.getboolean("snapshots", "save_successful", fallback=False) 148 | gtk_stdout = config.getboolean("debug", "gtk_stdout", fallback=False) 149 | rotate = config.getint("video", "rotate", fallback=0) 150 | 151 | # Send the gtk output to the terminal if enabled in the config 152 | gtk_pipe = sys.stdout if gtk_stdout else subprocess.DEVNULL 153 | 154 | # Start the auth ui, register it to be always be closed on exit 155 | try: 156 | gtk_proc = subprocess.Popen(["howdy-gtk", "--start-auth-ui"], stdin=subprocess.PIPE, stdout=gtk_pipe, stderr=gtk_pipe) 157 | atexit.register(exit) 158 | except FileNotFoundError: 159 | pass 160 | 161 | # Write to the stdin to redraw ui 162 | send_to_ui("M", _("Starting up...")) 163 | 164 | # Save the time needed to start the script 165 | timings["in"] = time.time() - timings["st"] 166 | 167 | # Import face recognition, takes some time 168 | timings["ll"] = time.time() 169 | 170 | # Start threading and wait for init to finish 171 | lock = thread.allocate_lock() 172 | lock.acquire() 173 | thread.start_new_thread(init_detector, (lock, )) 174 | 175 | # Start video capture on the IR camera 176 | timings["ic"] = time.time() 177 | 178 | video_capture = VideoCapture(config) 179 | 180 | # Read exposure from config to use in the main loop 181 | exposure = config.getint("video", "exposure", fallback=-1) 182 | 183 | # Note the time it took to open the camera 184 | timings["ic"] = time.time() - timings["ic"] 185 | 186 | # wait for thread to finish 187 | lock.acquire() 188 | lock.release() 189 | del lock 190 | 191 | # Fetch the max frame height 192 | max_height = config.getfloat("video", "max_height", fallback=320.0) 193 | 194 | # Get the height of the image (which would be the width if screen is portrait oriented) 195 | height = video_capture.internal.get(cv2.CAP_PROP_FRAME_HEIGHT) or 1 196 | if rotate == 2: 197 | height = video_capture.internal.get(cv2.CAP_PROP_FRAME_WIDTH) or 1 198 | # Calculate the amount the image has to shrink 199 | scaling_factor = (max_height / height) or 1 200 | 201 | # Fetch config settings out of the loop 202 | timeout = config.getint("video", "timeout", fallback=4) 203 | dark_threshold = config.getfloat("video", "dark_threshold", fallback=60) 204 | end_report = config.getboolean("debug", "end_report", fallback=False) 205 | 206 | # Initiate histogram equalization 207 | clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) 208 | 209 | # Let the ui know that we're ready 210 | send_to_ui("M", _("Identifying you...")) 211 | 212 | # Start the read loop 213 | frames = 0 214 | valid_frames = 0 215 | timings["fr"] = time.time() 216 | dark_running_total = 0 217 | 218 | while True: 219 | # Increment the frame count every loop 220 | frames += 1 221 | 222 | # Form a string to let the user know we're real busy 223 | ui_subtext = "Scanned " + str(valid_frames - dark_tries) + " frames" 224 | if (dark_tries > 1): 225 | ui_subtext += " (skipped " + str(dark_tries) + " dark frames)" 226 | # Show it in the ui as subtext 227 | send_to_ui("S", ui_subtext) 228 | 229 | # Stop if we've exceeded the time limit 230 | if time.time() - timings["fr"] > timeout: 231 | # Create a timeout snapshot if enabled 232 | if save_failed: 233 | make_snapshot(_("FAILED")) 234 | 235 | if dark_tries == valid_frames: 236 | print(_("All frames were too dark, please check dark_threshold in config")) 237 | print(_("Average darkness: {avg}, Threshold: {threshold}").format(avg=str(dark_running_total / max(1, valid_frames)), threshold=str(dark_threshold))) 238 | exit(13) 239 | else: 240 | exit(11) 241 | 242 | # Grab a single frame of video 243 | frame, gsframe = video_capture.read_frame() 244 | gsframe = clahe.apply(gsframe) 245 | 246 | # If snapshots have been turned on 247 | if save_failed or save_successful: 248 | # Start capturing frames for the snapshot 249 | if len(snapframes) < 3: 250 | snapframes.append(frame) 251 | 252 | # Create a histogram of the image with 8 values 253 | hist = cv2.calcHist([gsframe], [0], None, [8], [0, 256]) 254 | # All values combined for percentage calculation 255 | hist_total = np.sum(hist) 256 | 257 | # Calculate frame darkness 258 | darkness = (hist[0] / hist_total * 100) 259 | 260 | # If the image is fully black due to a bad camera read, 261 | # skip to the next frame 262 | if (hist_total == 0) or (darkness == 100): 263 | black_tries += 1 264 | continue 265 | 266 | dark_running_total += darkness 267 | valid_frames += 1 268 | 269 | # If the image exceeds darkness threshold due to subject distance, 270 | # skip to the next frame 271 | if (darkness > dark_threshold): 272 | dark_tries += 1 273 | continue 274 | 275 | # If the height is too high 276 | if scaling_factor != 1: 277 | # Apply that factor to the frame 278 | frame = cv2.resize(frame, None, fx=scaling_factor, fy=scaling_factor, interpolation=cv2.INTER_AREA) 279 | gsframe = cv2.resize(gsframe, None, fx=scaling_factor, fy=scaling_factor, interpolation=cv2.INTER_AREA) 280 | 281 | # If camera is configured to rotate = 1, check portrait in addition to landscape 282 | if rotate == 1: 283 | if frames % 3 == 1: 284 | frame = cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE) 285 | gsframe = cv2.rotate(gsframe, cv2.ROTATE_90_COUNTERCLOCKWISE) 286 | if frames % 3 == 2: 287 | frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) 288 | gsframe = cv2.rotate(gsframe, cv2.ROTATE_90_CLOCKWISE) 289 | 290 | # If camera is configured to rotate = 2, check portrait orientation 291 | elif rotate == 2: 292 | if frames % 2 == 0: 293 | frame = cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE) 294 | gsframe = cv2.rotate(gsframe, cv2.ROTATE_90_COUNTERCLOCKWISE) 295 | else: 296 | frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) 297 | gsframe = cv2.rotate(gsframe, cv2.ROTATE_90_CLOCKWISE) 298 | 299 | # Get all faces from that frame as encodings 300 | # Upsamples 1 time 301 | face_locations = face_detector(gsframe, 1) 302 | # Loop through each face 303 | for fl in face_locations: 304 | if use_cnn: 305 | fl = fl.rect 306 | 307 | # Fetch the faces in the image 308 | face_landmark = pose_predictor(frame, fl) 309 | face_encoding = np.array(face_encoder.compute_face_descriptor(frame, face_landmark, 1)) 310 | 311 | # Match this found face against a known face 312 | matches = np.linalg.norm(encodings - face_encoding, axis=1) 313 | 314 | # Get best match 315 | match_index = np.argmin(matches) 316 | match = matches[match_index] 317 | 318 | # Update certainty if we have a new low 319 | if lowest_certainty > match: 320 | lowest_certainty = match 321 | 322 | # Check if a match that's confident enough 323 | if 0 < match < video_certainty: 324 | timings["tt"] = time.time() - timings["st"] 325 | timings["fl"] = time.time() - timings["fr"] 326 | 327 | # If set to true in the config, print debug text 328 | if end_report: 329 | def print_timing(label, k): 330 | """Helper function to print a timing from the list""" 331 | print(" %s: %dms" % (label, round(timings[k] * 1000))) 332 | 333 | # Print a nice timing report 334 | print(_("Time spent")) 335 | print_timing(_("Starting up"), "in") 336 | print(_(" Open cam + load libs: %dms") % (round(max(timings["ll"], timings["ic"]) * 1000, ))) 337 | print_timing(_(" Opening the camera"), "ic") 338 | print_timing(_(" Importing recognition libs"), "ll") 339 | print_timing(_("Searching for known face"), "fl") 340 | print_timing(_("Total time"), "tt") 341 | 342 | print(_("\nResolution")) 343 | width = video_capture.fw or 1 344 | print(_(" Native: %dx%d") % (height, width)) 345 | # Save the new size for diagnostics 346 | scale_height, scale_width = frame.shape[:2] 347 | print(_(" Used: %dx%d") % (scale_height, scale_width)) 348 | 349 | # Show the total number of frames and calculate the FPS by dividing it by the total scan time 350 | print(_("\nFrames searched: %d (%.2f fps)") % (frames, frames / timings["fl"])) 351 | print(_("Black frames ignored: %d ") % (black_tries, )) 352 | print(_("Dark frames ignored: %d ") % (dark_tries, )) 353 | print(_("Certainty of winning frame: %.3f") % (match * 10, )) 354 | 355 | print(_("Winning model: %d (\"%s\")") % (match_index, models[match_index]["label"])) 356 | 357 | # Make snapshot if enabled 358 | if save_successful: 359 | make_snapshot(_("SUCCESSFUL")) 360 | 361 | # Run rubberstamps if enabled 362 | if config.getboolean("rubberstamps", "enabled", fallback=False): 363 | import rubberstamps 364 | 365 | send_to_ui("S", "") 366 | 367 | if "gtk_proc" not in vars(): 368 | gtk_proc = None 369 | 370 | rubberstamps.execute(config, gtk_proc, { 371 | "video_capture": video_capture, 372 | "face_detector": face_detector, 373 | "pose_predictor": pose_predictor, 374 | "clahe": clahe 375 | }) 376 | 377 | # End peacefully 378 | exit(0) 379 | 380 | if exposure != -1: 381 | # For a strange reason on some cameras (e.g. Lenoxo X1E) setting manual exposure works only after a couple frames 382 | # are captured and even after a delay it does not always work. Setting exposure at every frame is reliable though. 383 | video_capture.internal.set(cv2.CAP_PROP_AUTO_EXPOSURE, 1.0) # 1 = Manual 384 | video_capture.internal.set(cv2.CAP_PROP_EXPOSURE, float(exposure)) 385 | -------------------------------------------------------------------------------- /howdy/src/config.ini: -------------------------------------------------------------------------------- 1 | # Howdy config file 2 | # Press CTRL + X to save in the nano editor 3 | 4 | [core] 5 | # Print that face detection is being attempted 6 | detection_notice = false 7 | 8 | # Print that face detection has timed out 9 | timeout_notice = true 10 | 11 | # Do not print anything when a face verification succeeds 12 | no_confirmation = false 13 | 14 | # When a user without a known face model tries to use this script, don't 15 | # show an error but fail silently 16 | suppress_unknown = false 17 | 18 | # Disable Howdy in remote shells 19 | abort_if_ssh = true 20 | 21 | # Disable Howdy if lid is closed 22 | abort_if_lid_closed = true 23 | 24 | # Disable howdy in the PAM 25 | # The howdy command will still function 26 | disabled = false 27 | 28 | # Use CNN instead of HOG 29 | # CNN model is much more accurate than the HOG based model, but takes much more 30 | # power to run, and is meant to be executed on a GPU to attain reasonable speed. 31 | use_cnn = false 32 | 33 | # Set a workaround to do face and password authentication at the same time 34 | # off user will have to press enter themselves after a Howdy timeout 35 | # input will send an enter keypress to stop the password prompt 36 | # native will stop the prompt at PAM level (can lead to instability!) 37 | workaround = off 38 | 39 | [video] 40 | # The certainty of the detected face belonging to the user of the account 41 | # On a scale from 1 to 10, values above 5 are not recommended 42 | # The lower, the more accurate 43 | certainty = 3.5 44 | 45 | # The number of seconds to search before timing out 46 | timeout = 4 47 | 48 | # The path of the device to capture frames from 49 | # Video devices are usually found in /dev/v4l/by-path/ 50 | device_path = none 51 | 52 | # Print a warning if the the video device is not found 53 | warn_no_device = true 54 | 55 | # Scale down the video feed to this maximum height 56 | # Speeds up face recognition but can make it less precise 57 | max_height = 320 58 | 59 | # Set the camera input profile to this width and height 60 | # The largest profile will be used if set to -1 61 | # Automatically ignored if not a valid profile 62 | frame_width = -1 63 | frame_height = -1 64 | 65 | # Because of flashing IR emitters, some frames can be completely unlit 66 | # Skip the frame if the lowest 1/8 of the histogram is above this percentage 67 | # of the total 68 | # The lower this setting is, the more dark frames are ignored 69 | dark_threshold = 60 70 | 71 | # The recorder to use. Can be either opencv (default), ffmpeg or pyv4l2. 72 | # Switching from the default opencv to ffmpeg can help with grayscale issues. 73 | recording_plugin = opencv 74 | 75 | # Video format used by ffmpeg. Options include vfwcap or v4l2. 76 | # FFMPEG only. 77 | device_format = v4l2 78 | 79 | # Force the use of Motion JPEG when decoding frames, fixes issues with YUYV 80 | # raw frame decoding. 81 | # OPENCV only. 82 | force_mjpeg = false 83 | 84 | # Specify exposure value explicitly. This disables autoexposure. 85 | # Use qv4l2 to determine an appropriate value. 86 | # OPENCV only. 87 | exposure = -1 88 | 89 | # Specify frame rate of the capture device. 90 | # Some IR emitters will not function properly at the default framerate. 91 | # Use qv4l2 to determine an appropriate value. 92 | # OPENCV only. 93 | device_fps = -1 94 | 95 | # Rotate captured frames so faces are upright. 96 | # 0 Check landscape orientation only 97 | # 1 Check both landscape and portrait orientation 98 | # 2 Check portrait orientation only 99 | rotate = 0 100 | 101 | [snapshots] 102 | # Capture snapshots of failed login attempts and save them to disk with metadata 103 | # Snapshots are saved to /var/log/howdy/snapshots 104 | save_failed = false 105 | 106 | # Do the same as the option above but for successful attempts 107 | save_successful = false 108 | 109 | [rubberstamps] 110 | # Enable specific extra checks after the user has been recognised 111 | enabled = false 112 | 113 | # What type of stamps to run and with what options. The type, timeout and 114 | # failure mode are required. One line per stamp. Rule syntax: 115 | # stamptype timeout (failsafe | faildeadly) [extra_argument=value] 116 | stamp_rules = 117 | nod 5s failsafe min_distance=12 118 | 119 | [debug] 120 | # Show a short but detailed diagnostic report in console 121 | # Enabling this can cause some UI apps to fail, only enable it to debug 122 | end_report = false 123 | 124 | # More verbose logging from the rubberstamps system 125 | verbose_stamps = false 126 | 127 | # Pass output of the GTK auth window to the terminal 128 | gtk_stdout = false 129 | -------------------------------------------------------------------------------- /howdy/src/dlib-data/.gitignore: -------------------------------------------------------------------------------- 1 | *.dat 2 | *.dat.bz2 3 | -------------------------------------------------------------------------------- /howdy/src/dlib-data/Readme.md: -------------------------------------------------------------------------------- 1 | Download and unpack `dlib` data files from https://github.com/davisking/dlib-models repository: 2 | 3 | ``` 4 | shell 5 | wget https://github.com/davisking/dlib-models/raw/master/dlib_face_recognition_resnet_model_v1.dat.bz2 6 | wget https://github.com/davisking/dlib-models/raw/master/mmod_human_face_detector.dat.bz2 7 | wget https://github.com/davisking/dlib-models/raw/master/shape_predictor_5_face_landmarks.dat.bz2 8 | bunzip *bz2 9 | ``` 10 | -------------------------------------------------------------------------------- /howdy/src/dlib-data/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Downloading 3 required data files..." 4 | 5 | # Check if wget is installed 6 | if hash wget;then 7 | # Check if wget supports the option to only show the progress bar 8 | wget --help | grep -q "\--show-progress" && \ 9 | _PROGRESS_OPT="-q --show-progress" || _PROGRESS_OPT="" 10 | 11 | # Download the archives 12 | wget $_PROGRESS_OPT --tries 5 https://github.com/davisking/dlib-models/raw/master/dlib_face_recognition_resnet_model_v1.dat.bz2 13 | wget $_PROGRESS_OPT --tries 5 https://github.com/davisking/dlib-models/raw/master/mmod_human_face_detector.dat.bz2 14 | wget $_PROGRESS_OPT --tries 5 https://github.com/davisking/dlib-models/raw/master/shape_predictor_5_face_landmarks.dat.bz2 15 | 16 | # Otherwise fall back on curl 17 | else 18 | curl --location --retry 5 --output dlib_face_recognition_resnet_model_v1.dat.bz2 https://github.com/davisking/dlib-models/raw/master/dlib_face_recognition_resnet_model_v1.dat.bz2 19 | curl --location --retry 5 --output mmod_human_face_detector.dat.bz2 https://github.com/davisking/dlib-models/raw/master/mmod_human_face_detector.dat.bz2 20 | curl --location --retry 5 --output shape_predictor_5_face_landmarks.dat.bz2 https://github.com/davisking/dlib-models/raw/master/shape_predictor_5_face_landmarks.dat.bz2 21 | fi 22 | 23 | # Uncompress the data files and delete the original archive 24 | echo " " 25 | echo "Unpacking..." 26 | bzip2 -d -f *.bz2 27 | -------------------------------------------------------------------------------- /howdy/src/i18n.py: -------------------------------------------------------------------------------- 1 | # Support file for translations 2 | 3 | # Import modules 4 | import gettext 5 | import os 6 | 7 | # Get the right translation based on locale, falling back to base if none found 8 | translation = gettext.translation("core", localedir=os.path.join(os.path.dirname(__file__), 'locales'), fallback=True) 9 | translation.install() 10 | 11 | # Export translation function as _ 12 | _ = translation.gettext 13 | -------------------------------------------------------------------------------- /howdy/src/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boltgolt/howdy/c4521c14ab8c672cadbc826a3dbec9ef95b7adb1/howdy/src/logo.png -------------------------------------------------------------------------------- /howdy/src/meson.build: -------------------------------------------------------------------------------- 1 | if meson.is_subproject() 2 | project('howdy', 'cpp', license: 'MIT', version: 'beta', meson_version: '>= 0.64.0') 3 | endif 4 | 5 | 6 | datadir = get_option('prefix') / get_option('datadir') / 'howdy' 7 | py_conf = configuration_data(paths_dict) 8 | py_conf.set('data_dir', datadir) 9 | 10 | py = import('python').find_installation(paths_dict.get('python_path')) 11 | py.dependency() 12 | 13 | py_paths = configure_file( 14 | input: 'paths.py.in', 15 | output: 'paths.py', 16 | configuration: py_conf, 17 | ) 18 | 19 | py_sources = [ 20 | 'cli/__init__.py', 21 | 'cli/add.py', 22 | 'cli/clear.py', 23 | 'cli/config.py', 24 | 'cli/disable.py', 25 | 'cli/list.py', 26 | 'cli/remove.py', 27 | 'cli/set.py', 28 | 'cli/snap.py', 29 | 'cli/test.py', 30 | 'cli.py', 31 | 'compare.py', 32 | 'i18n.py', 33 | 'paths_factory.py', 34 | 'recorders/__init__.py', 35 | 'recorders/ffmpeg_reader.py', 36 | 'recorders/pyv4l2_reader.py', 37 | 'recorders/v4l2.py', 38 | 'recorders/video_capture.py', 39 | 'rubberstamps/__init__.py', 40 | 'rubberstamps/hotkey.py', 41 | 'rubberstamps/nod.py', 42 | 'snapshot.py', 43 | py_paths, 44 | ] 45 | 46 | # Include PAM module 47 | if get_option('install_in_site_packages') 48 | pysourcesinstalldir = join_paths(py.get_install_dir(), 'howdy') 49 | else 50 | pysourcesinstalldir = get_option('py_sources_dir') != '' ? get_option('py_sources_dir') / 'howdy' : join_paths(get_option('prefix'), get_option('libdir'), 'howdy') 51 | endif 52 | 53 | pam_module_conf_data = configuration_data(paths_dict) 54 | pam_module_conf_data.set('compare_script_path', join_paths(pysourcesinstalldir, 'compare.py')) 55 | pam_module_conf_data.set('config_file_path', config_path) 56 | subdir('pam') 57 | if get_option('install_pam_config') 58 | # pamdir is inherited from the pam subproject 59 | pam_config = configure_file( 60 | input: 'pam-config/howdy.in', 61 | output: 'pam-config', 62 | configuration: {'pamdir': pamdir} 63 | ) 64 | install_data( 65 | pam_config, 66 | install_dir: get_option('prefix') / get_option('datadir') / 'pam-configs', 67 | install_mode: 'rwxr-xr-x', 68 | install_tag: 'pam', 69 | rename: 'howdy', 70 | ) 71 | endif 72 | 73 | if get_option('install_in_site_packages') 74 | py.install_sources( 75 | py_sources, 76 | subdir: 'howdy', 77 | preserve_path: true, 78 | install_tag: 'py_sources', 79 | ) 80 | else 81 | install_data( 82 | py_sources, 83 | preserve_path: true, 84 | install_dir: pysourcesinstalldir, 85 | install_mode: 'r--r--r--', 86 | install_tag: 'py_sources', 87 | ) 88 | endif 89 | 90 | install_data('logo.png', install_tag: 'meta') 91 | autocomplete = configure_file( 92 | input: 'autocomplete/howdy.in', 93 | output: 'autocomplete', 94 | configuration: configuration_data({ 'config_path': config_path }) 95 | ) 96 | install_data( 97 | autocomplete, 98 | install_dir: join_paths(get_option('prefix'), get_option('datadir'), 'bash-completion', 'completions'), 99 | install_mode: 'rwxr--r--', 100 | install_tag: 'bash_completion', 101 | rename: 'howdy', 102 | ) 103 | 104 | fs = import('fs') 105 | if not fs.exists(config_path) 106 | install_data('config.ini', install_dir: confdir, install_mode: 'rwxr--r--', install_tag: 'config') 107 | endif 108 | 109 | install_data('dlib-data/install.sh', 'dlib-data/Readme.md', install_dir: dlibdatadir, install_mode: 'rwxr--r--') 110 | 111 | install_man('../howdy.1') 112 | 113 | # if get_option('fetch_dlib_data') 114 | # downloader = find_program('wget') 115 | # bunzip2 = find_program('bunzip2') 116 | 117 | # links = [ 118 | # 'https://github.com/davisking/dlib-models/raw/master/dlib_face_recognition_resnet_model_v1.dat.bz2', 119 | # 'https://github.com/davisking/dlib-models/raw/master/mmod_human_face_detector.dat.bz2', 120 | # 'https://github.com/davisking/dlib-models/raw/master/shape_predictor_5_face_landmarks.dat.bz2' 121 | # ] 122 | 123 | # archived_model_files = [ 124 | # 'dlib_face_recognition_resnet_model_v1.dat.bz2', 125 | # 'shape_predictor_5_face_landmarks.dat.bz2', 126 | # 'mmod_human_face_detector.dat.bz2' 127 | # ] 128 | 129 | # download = run_command( 130 | # 'download', 131 | # links, 132 | # output: archived_model_files, 133 | # command: [downloader, '-O', '@OUTPUT@', '@INPUT@'] 134 | # ) 135 | 136 | # model_files = [ 137 | # 'dlib_face_recognition_resnet_model_v1.dat', 138 | # 'shape_predictor_5_face_landmarks.dat', 139 | # 'mmod_human_face_detector.dat' 140 | # ] 141 | 142 | # models = custom_target( 143 | # 'models', 144 | # input: archived_model_files, 145 | # output: model_files, 146 | # command: [bunzip2, '-k', '@INPUT@'], 147 | # ) 148 | 149 | # install_data( 150 | # model_files, 151 | # install_dir: join_paths(get_option('prefix'), get_option('libdir'), 'dlib_models'), 152 | # ) 153 | 154 | # endif 155 | 156 | cli_path = join_paths(pysourcesinstalldir, 'cli.py') 157 | conf_data = configuration_data({ 'script_path': cli_path, 'python_path': py.full_path() }) 158 | 159 | bin_name = 'howdy' 160 | bin = configure_file( 161 | input: 'bin/howdy.in', 162 | output: bin_name, 163 | configuration: conf_data 164 | ) 165 | install_data( 166 | bin, 167 | install_mode: 'rwxr-xr-x', 168 | install_dir: get_option('bindir'), 169 | install_tag: 'bin', 170 | ) 171 | -------------------------------------------------------------------------------- /howdy/src/pam-config/howdy.in: -------------------------------------------------------------------------------- 1 | Name: Howdy 2 | Default: yes 3 | Priority: 512 4 | Auth-Type: Primary 5 | Auth: 6 | [success=end default=ignore] @pamdir@/pam_howdy.so 7 | -------------------------------------------------------------------------------- /howdy/src/pam/.clang-tidy: -------------------------------------------------------------------------------- 1 | ../.clang-tidy -------------------------------------------------------------------------------- /howdy/src/pam/.gitignore: -------------------------------------------------------------------------------- 1 | subprojects/inih/ 2 | -------------------------------------------------------------------------------- /howdy/src/pam/README.md: -------------------------------------------------------------------------------- 1 | # Howdy PAM module 2 | 3 | ## Requirements 4 | 5 | This module depends on `INIReader` and `libevdev`. 6 | They can be installed with these packages: 7 | 8 | ``` 9 | Arch Linux - libinih libevdev 10 | Debian - libinih-dev libevdev-dev 11 | Fedora - inih-devel libevdev-devel 12 | OpenSUSE - inih libevdev-devel 13 | ``` 14 | 15 | If your distribution doesn't provide `INIReader`, 16 | it will be automatically pulled from git at the subproject's pinned version. 17 | 18 | ## Build 19 | 20 | ``` sh 21 | meson setup build 22 | ninja -C build # or meson compile -C build 23 | ``` 24 | 25 | ## Install 26 | 27 | ``` sh 28 | meson install -C build 29 | ``` 30 | 31 | Add the following line to your PAM configuration (/etc/pam.d/your-service): 32 | 33 | ``` pam 34 | auth sufficient pam_howdy.so 35 | ``` 36 | -------------------------------------------------------------------------------- /howdy/src/pam/enter_device.cc: -------------------------------------------------------------------------------- 1 | #include "enter_device.hh" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | EnterDevice::EnterDevice() 8 | : raw_device(libevdev_new(), &libevdev_free), 9 | raw_uinput_device(nullptr, &libevdev_uinput_destroy) { 10 | auto *dev_ptr = raw_device.get(); 11 | 12 | libevdev_set_name(dev_ptr, "enter device"); 13 | libevdev_enable_event_type(dev_ptr, EV_KEY); 14 | libevdev_enable_event_code(dev_ptr, EV_KEY, KEY_ENTER, nullptr); 15 | 16 | int err; 17 | struct libevdev_uinput *uinput_dev_ptr; 18 | if ((err = libevdev_uinput_create_from_device( 19 | dev_ptr, LIBEVDEV_UINPUT_OPEN_MANAGED, &uinput_dev_ptr)) != 0) { 20 | throw std::runtime_error(std::string("Failed to create device: ") + 21 | strerror(-err)); 22 | } 23 | 24 | raw_uinput_device.reset(uinput_dev_ptr); 25 | }; 26 | 27 | void EnterDevice::send_enter_press() const { 28 | auto *uinput_dev_ptr = raw_uinput_device.get(); 29 | 30 | int err; 31 | if ((err = libevdev_uinput_write_event(uinput_dev_ptr, EV_KEY, KEY_ENTER, 32 | 1)) != 0) { 33 | throw std::runtime_error(std::string("Failed to write event: ") + 34 | strerror(-err)); 35 | } 36 | 37 | if ((err = libevdev_uinput_write_event(uinput_dev_ptr, EV_KEY, KEY_ENTER, 38 | 0)) != 0) { 39 | throw std::runtime_error(std::string("Failed to write event: ") + 40 | strerror(-err)); 41 | } 42 | 43 | if ((err = libevdev_uinput_write_event(uinput_dev_ptr, EV_SYN, SYN_REPORT, 44 | 0)) != 0) { 45 | throw std::runtime_error(std::string("Failed to write event: ") + 46 | strerror(-err)); 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /howdy/src/pam/enter_device.hh: -------------------------------------------------------------------------------- 1 | #ifndef ENTER_DEVICE_H_ 2 | #define ENTER_DEVICE_H_ 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | class EnterDevice { 9 | std::unique_ptr raw_device; 10 | std::unique_ptr 11 | raw_uinput_device; 12 | 13 | public: 14 | EnterDevice(); 15 | void send_enter_press() const; 16 | ~EnterDevice() = default; 17 | }; 18 | 19 | #endif // ENTER_DEVICE_H 20 | -------------------------------------------------------------------------------- /howdy/src/pam/main.cc: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | 18 | #include 19 | #include 20 | #include 21 | #include 22 | #include 23 | #include 24 | #include 25 | #include 26 | #include 27 | 28 | #include 29 | 30 | #include 31 | #include 32 | #include 33 | 34 | #include "enter_device.hh" 35 | #include "main.hh" 36 | #include "optional_task.hh" 37 | #include 38 | 39 | const auto DEFAULT_TIMEOUT = 40 | std::chrono::duration(100); 41 | const auto MAX_RETRIES = 5; 42 | 43 | #define S(msg) gettext(msg) 44 | 45 | /** 46 | * Inspect the status code returned by the compare process 47 | * @param status The status code 48 | * @param conv_function The PAM conversation function 49 | * @return A PAM return code 50 | */ 51 | auto howdy_error(int status, 52 | const std::function &conv_function) 53 | -> int { 54 | // If the process has exited 55 | if (WIFEXITED(status)) { 56 | // Get the status code returned 57 | status = WEXITSTATUS(status); 58 | 59 | switch (status) { 60 | case CompareError::NO_FACE_MODEL: 61 | syslog(LOG_NOTICE, "Failure, no face model known"); 62 | break; 63 | case CompareError::TIMEOUT_REACHED: 64 | conv_function(PAM_ERROR_MSG, S("Failure, timeout reached")); 65 | syslog(LOG_ERR, "Failure, timeout reached"); 66 | break; 67 | case CompareError::ABORT: 68 | syslog(LOG_ERR, "Failure, general abort"); 69 | break; 70 | case CompareError::TOO_DARK: 71 | conv_function(PAM_ERROR_MSG, S("Face detection image too dark")); 72 | syslog(LOG_ERR, "Failure, image too dark"); 73 | break; 74 | case CompareError::INVALID_DEVICE: 75 | syslog(LOG_ERR, 76 | "Failure, not possible to open camera at configured path"); 77 | break; 78 | default: 79 | conv_function(PAM_ERROR_MSG, 80 | std::string(S("Unknown error: ") + status).c_str()); 81 | syslog(LOG_ERR, "Failure, unknown error %d", status); 82 | } 83 | } else if (WIFSIGNALED(status)) { 84 | // We get the signal 85 | status = WTERMSIG(status); 86 | 87 | syslog(LOG_ERR, "Child killed by signal %s (%d)", strsignal(status), 88 | status); 89 | } 90 | 91 | // As this function is only called for error status codes, signal an error to 92 | // PAM 93 | return PAM_AUTH_ERR; 94 | } 95 | 96 | /** 97 | * Format the success message if the status is successful or log the error in 98 | * the other case 99 | * @param username Username 100 | * @param status Status code 101 | * @param config INI configuration 102 | * @param conv_function PAM conversation function 103 | * @return Returns the conversation function return code 104 | */ 105 | auto howdy_status(char *username, int status, const INIReader &config, 106 | const std::function &conv_function) 107 | -> int { 108 | if (status != EXIT_SUCCESS) { 109 | return howdy_error(status, conv_function); 110 | } 111 | 112 | if (!config.GetBoolean("core", "no_confirmation", true)) { 113 | // Construct confirmation text from i18n string 114 | std::string confirm_text(S("Identified face as {}")); 115 | std::string identify_msg = 116 | confirm_text.replace(confirm_text.find("{}"), 2, std::string(username)); 117 | conv_function(PAM_TEXT_INFO, identify_msg.c_str()); 118 | } 119 | 120 | syslog(LOG_INFO, "Login approved"); 121 | 122 | return PAM_SUCCESS; 123 | } 124 | 125 | /** 126 | * Check if Howdy should be enabled according to the configuration and the 127 | * environment. 128 | * @param config INI configuration 129 | * @param username Username 130 | * @return Returns PAM_AUTHINFO_UNAVAIL if it shouldn't be enabled, 131 | * PAM_SUCCESS otherwise 132 | */ 133 | auto check_enabled(const INIReader &config, const char *username) -> int { 134 | // Stop executing if Howdy has been disabled in the config 135 | if (config.GetBoolean("core", "disabled", false)) { 136 | syslog(LOG_INFO, "Skipped authentication, Howdy is disabled"); 137 | return PAM_AUTHINFO_UNAVAIL; 138 | } 139 | 140 | // Stop if we're in a remote shell and configured to exit 141 | if (config.GetBoolean("core", "abort_if_ssh", true)) { 142 | if (checkenv("SSH_CONNECTION") || checkenv("SSH_CLIENT") || 143 | checkenv("SSH_TTY") || checkenv("SSHD_OPTS")) { 144 | syslog(LOG_INFO, "Skipped authentication, SSH session detected"); 145 | return PAM_AUTHINFO_UNAVAIL; 146 | } 147 | } 148 | 149 | // Try to detect the laptop lid state and stop if it's closed 150 | if (config.GetBoolean("core", "abort_if_lid_closed", true)) { 151 | glob_t glob_result; 152 | 153 | // Get any files containing lid state 154 | int return_value = 155 | glob("/proc/acpi/button/lid/*/state", 0, nullptr, &glob_result); 156 | 157 | if (return_value != 0) { 158 | syslog(LOG_ERR, "Failed to read files from glob: %d", return_value); 159 | if (errno != 0) { 160 | syslog(LOG_ERR, "Underlying error: %s (%d)", strerror(errno), errno); 161 | } 162 | } else { 163 | for (size_t i = 0; i < glob_result.gl_pathc; i++) { 164 | std::ifstream file(std::string(glob_result.gl_pathv[i])); 165 | std::string lid_state; 166 | std::getline(file, lid_state, static_cast(file.eof())); 167 | 168 | if (lid_state.find("closed") != std::string::npos) { 169 | globfree(&glob_result); 170 | 171 | syslog(LOG_INFO, "Skipped authentication, closed lid detected"); 172 | return PAM_AUTHINFO_UNAVAIL; 173 | } 174 | } 175 | } 176 | globfree(&glob_result); 177 | } 178 | 179 | // pre-check if this user has face model file 180 | auto model_path = std::string(USER_MODELS_DIR) + "/" + username + ".dat"; 181 | struct stat stat_; 182 | if (stat(model_path.c_str(), &stat_) != 0) { 183 | return PAM_AUTHINFO_UNAVAIL; 184 | } 185 | 186 | return PAM_SUCCESS; 187 | } 188 | 189 | /** 190 | * The main function, runs the identification and authentication 191 | * @param pamh The handle to interface directly with PAM 192 | * @param flags Flags passed on to us by PAM, XORed 193 | * @param argc Amount of rules in the PAM config (disregared) 194 | * @param argv Options defined in the PAM config 195 | * @param ask_auth_tok True if we should ask for a password too 196 | * @return Returns a PAM return code 197 | */ 198 | auto identify(pam_handle_t *pamh, int flags, int argc, const char **argv, 199 | bool ask_auth_tok) -> int { 200 | INIReader config(CONFIG_FILE_PATH); 201 | openlog("pam_howdy", 0, LOG_AUTHPRIV); 202 | 203 | // Error out if we could not read the config file 204 | if (config.ParseError() != 0) { 205 | syslog(LOG_ERR, "Failed to parse the configuration file: %d", 206 | config.ParseError()); 207 | return PAM_SYSTEM_ERR; 208 | } 209 | 210 | // Will contain the responses from PAM functions 211 | int pam_res = PAM_IGNORE; 212 | 213 | // Get the username from PAM, needed to match correct face model 214 | char *username = nullptr; 215 | if ((pam_res = pam_get_user(pamh, const_cast(&username), 216 | nullptr)) != PAM_SUCCESS) { 217 | syslog(LOG_ERR, "Failed to get username"); 218 | return pam_res; 219 | } 220 | 221 | // Check if we should continue 222 | if ((pam_res = check_enabled(config, username)) != PAM_SUCCESS) { 223 | return pam_res; 224 | } 225 | 226 | Workaround workaround = 227 | get_workaround(config.GetString("core", "workaround", "input")); 228 | 229 | // Will contain PAM conversation structure 230 | struct pam_conv *conv = nullptr; 231 | const void **conv_ptr = 232 | const_cast(reinterpret_cast(&conv)); 233 | 234 | if ((pam_res = pam_get_item(pamh, PAM_CONV, conv_ptr)) != PAM_SUCCESS) { 235 | syslog(LOG_ERR, "Failed to acquire conversation"); 236 | return pam_res; 237 | } 238 | 239 | // Wrap the PAM conversation function in our own, easier function 240 | auto conv_function = [conv](int msg_type, const char *msg_str) { 241 | const struct pam_message msg = {.msg_style = msg_type, .msg = msg_str}; 242 | const struct pam_message *msgp = &msg; 243 | 244 | struct pam_response res = {}; 245 | struct pam_response *resp = &res; 246 | 247 | return conv->conv(1, &msgp, &resp, conv->appdata_ptr); 248 | }; 249 | 250 | // Initialize gettext 251 | setlocale(LC_ALL, ""); 252 | bindtextdomain(GETTEXT_PACKAGE, LOCALEDIR); 253 | textdomain(GETTEXT_PACKAGE); 254 | 255 | if (config.GetBoolean("core", "detection_notice", true)) { 256 | if ((conv_function(PAM_TEXT_INFO, S("Attempting facial authentication"))) != 257 | PAM_SUCCESS) { 258 | syslog(LOG_ERR, "Failed to send detection notice"); 259 | } 260 | } 261 | 262 | const char *const args[] = {PYTHON_EXECUTABLE_PATH, // NOLINT 263 | COMPARE_PROCESS_PATH, username, nullptr}; 264 | pid_t child_pid; 265 | 266 | // Start the python subprocess 267 | if (posix_spawnp(&child_pid, PYTHON_EXECUTABLE_PATH, nullptr, nullptr, 268 | const_cast(args), nullptr) != 0) { 269 | syslog(LOG_ERR, "Can't spawn the howdy process: %s (%d)", strerror(errno), 270 | errno); 271 | return PAM_SYSTEM_ERR; 272 | } 273 | 274 | // NOTE: We should replace mutex and condition_variable by atomic wait, but 275 | // it's too recent (C++20) 276 | std::mutex mutx; 277 | std::condition_variable convar; 278 | ConfirmationType confirmation_type(ConfirmationType::Unset); 279 | 280 | // This task wait for the status of the python subprocess (we don't want a 281 | // zombie process) 282 | optional_task child_task([&] { 283 | int status; 284 | waitpid(child_pid, &status, 0); 285 | { 286 | std::unique_lock lock(mutx); 287 | if (confirmation_type == ConfirmationType::Unset) { 288 | confirmation_type = ConfirmationType::Howdy; 289 | } 290 | } 291 | convar.notify_one(); 292 | 293 | return status; 294 | }); 295 | child_task.activate(); 296 | 297 | // This task waits for the password input (if the workaround wants it) 298 | optional_task> pass_task([&] { 299 | char *auth_tok_ptr = nullptr; 300 | int pam_res = pam_get_authtok( 301 | pamh, PAM_AUTHTOK, const_cast(&auth_tok_ptr), nullptr); 302 | { 303 | std::unique_lock lock(mutx); 304 | if (confirmation_type == ConfirmationType::Unset) { 305 | confirmation_type = ConfirmationType::Pam; 306 | } 307 | } 308 | convar.notify_one(); 309 | 310 | return std::tuple(pam_res, auth_tok_ptr); 311 | }); 312 | 313 | auto ask_pass = ask_auth_tok && workaround != Workaround::Off; 314 | 315 | // We ask for the password if the function requires it and if a workaround is 316 | // set 317 | if (ask_pass) { 318 | pass_task.activate(); 319 | } 320 | 321 | // Wait for the end either of the child or the password input 322 | { 323 | std::unique_lock lock(mutx); 324 | convar.wait(lock, 325 | [&] { return confirmation_type != ConfirmationType::Unset; }); 326 | } 327 | 328 | // The password has been entered or an error has occurred 329 | if (confirmation_type == ConfirmationType::Pam) { 330 | // We kill the child because we don't need its result 331 | kill(child_pid, SIGTERM); 332 | child_task.stop(false); 333 | 334 | // We just wait for the thread to stop since it's this one which sent us the 335 | // confirmation type 336 | pass_task.stop(false); 337 | 338 | char *password = nullptr; 339 | std::tie(pam_res, password) = pass_task.get(); 340 | 341 | if (pam_res != PAM_SUCCESS) { 342 | return pam_res; 343 | } 344 | 345 | // The password has been entered, we are passing it to PAM stack 346 | return PAM_IGNORE; 347 | } 348 | 349 | // The compare process has finished its execution 350 | child_task.stop(false); 351 | 352 | // Get python process status code 353 | int status = child_task.get(); 354 | 355 | // If python process ran into a timeout 356 | // Do not send enter presses or terminate the PAM function, as the user might 357 | // still be typing their password 358 | if (WIFEXITED(status) && WEXITSTATUS(status) != EXIT_SUCCESS && ask_pass) { 359 | // Wait for the password to be typed 360 | pass_task.stop(false); 361 | 362 | char *password = nullptr; 363 | std::tie(pam_res, password) = pass_task.get(); 364 | 365 | if (pam_res != PAM_SUCCESS) { 366 | return howdy_status(username, status, config, conv_function); 367 | } 368 | 369 | // The password has been entered, we are passing it to PAM stack 370 | return PAM_IGNORE; 371 | } 372 | 373 | // We want to stop the password prompt, either by canceling the thread when 374 | // workaround is set to "native", or by emulating "Enter" input with 375 | // "input" 376 | 377 | // UNSAFE: We cancel the thread using pthread, pam_get_authtok seems to be 378 | // a cancellation point 379 | if (workaround == Workaround::Native) { 380 | pass_task.stop(true); 381 | } else if (workaround == Workaround::Input) { 382 | // We check if we have the right permissions on /dev/uinput 383 | if (euidaccess("/dev/uinput", W_OK | R_OK) != 0) { 384 | syslog(LOG_WARNING, "Insufficient permissions to create the fake device"); 385 | conv_function(PAM_ERROR_MSG, 386 | S("Insufficient permissions to send Enter " 387 | "press, waiting for user to press it instead")); 388 | } else { 389 | try { 390 | EnterDevice enter_device; 391 | int retries; 392 | 393 | // We try to send it 394 | enter_device.send_enter_press(); 395 | 396 | for (retries = 0; 397 | retries < MAX_RETRIES && 398 | pass_task.wait(DEFAULT_TIMEOUT) == std::future_status::timeout; 399 | retries++) { 400 | enter_device.send_enter_press(); 401 | } 402 | 403 | if (retries == MAX_RETRIES) { 404 | syslog(LOG_WARNING, 405 | "Failed to send enter input before the retries limit"); 406 | conv_function(PAM_ERROR_MSG, S("Failed to send Enter press, waiting " 407 | "for user to press it instead")); 408 | } 409 | } catch (std::runtime_error &err) { 410 | syslog(LOG_WARNING, "Failed to send enter input: %s", err.what()); 411 | conv_function(PAM_ERROR_MSG, S("Failed to send Enter press, waiting " 412 | "for user to press it instead")); 413 | } 414 | } 415 | 416 | // We stop the thread (will block until the enter key is pressed if the 417 | // input wasn't focused or if the uinput device failed to send keypress) 418 | pass_task.stop(false); 419 | } 420 | 421 | return howdy_status(username, status, config, conv_function); 422 | } 423 | 424 | // Called by PAM when a user needs to be authenticated, for example by running 425 | // the sudo command 426 | PAM_EXTERN auto pam_sm_authenticate(pam_handle_t *pamh, int flags, int argc, 427 | const char **argv) -> int { 428 | return identify(pamh, flags, argc, argv, true); 429 | } 430 | 431 | // Called by PAM when a session is started, such as by the su command 432 | PAM_EXTERN auto pam_sm_open_session(pam_handle_t *pamh, int flags, int argc, 433 | const char **argv) -> int { 434 | return identify(pamh, flags, argc, argv, false); 435 | } 436 | 437 | // The functions below are required by PAM, but not needed in this module 438 | PAM_EXTERN auto pam_sm_acct_mgmt(pam_handle_t *pamh, int flags, int argc, 439 | const char **argv) -> int { 440 | return PAM_IGNORE; 441 | } 442 | PAM_EXTERN auto pam_sm_close_session(pam_handle_t *pamh, int flags, int argc, 443 | const char **argv) -> int { 444 | return PAM_IGNORE; 445 | } 446 | PAM_EXTERN auto pam_sm_chauthtok(pam_handle_t *pamh, int flags, int argc, 447 | const char **argv) -> int { 448 | return PAM_IGNORE; 449 | } 450 | PAM_EXTERN auto pam_sm_setcred(pam_handle_t *pamh, int flags, int argc, 451 | const char **argv) -> int { 452 | return PAM_IGNORE; 453 | } 454 | -------------------------------------------------------------------------------- /howdy/src/pam/main.hh: -------------------------------------------------------------------------------- 1 | #ifndef MAIN_H_ 2 | #define MAIN_H_ 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | enum class ConfirmationType { Unset, Howdy, Pam }; 9 | 10 | enum class Workaround { Off, Input, Native }; 11 | 12 | // Exit status codes returned by the compare process 13 | enum CompareError : int { 14 | NO_FACE_MODEL = 10, 15 | TIMEOUT_REACHED = 11, 16 | ABORT = 12, 17 | TOO_DARK = 13, 18 | INVALID_DEVICE = 14, 19 | RUBBERSTAMP = 15 20 | }; 21 | 22 | inline auto get_workaround(const std::string &workaround) -> Workaround { 23 | if (workaround == "input") { 24 | return Workaround::Input; 25 | } 26 | 27 | if (workaround == "native") { 28 | return Workaround::Native; 29 | } 30 | 31 | return Workaround::Off; 32 | } 33 | 34 | /** 35 | * Check if an environment variable exists either in the environ array or using 36 | * getenv. 37 | * @param name The name of the environment variable. 38 | * @return The value of the environment variable or nullptr if it doesn't exist 39 | * or environ is nullptr. 40 | * @note This function was created because `getenv` wasn't working properly in 41 | * some contexts (like sudo). 42 | */ 43 | auto checkenv(const char *name) -> bool { 44 | if (std::getenv(name) != nullptr) { 45 | return true; 46 | } 47 | 48 | auto len = strlen(name); 49 | 50 | for (char **env = environ; *env != nullptr; env++) { 51 | if (strncmp(*env, name, len) == 0) { 52 | return true; 53 | } 54 | } 55 | 56 | return false; 57 | } 58 | 59 | #endif // MAIN_H_ 60 | -------------------------------------------------------------------------------- /howdy/src/pam/meson.build: -------------------------------------------------------------------------------- 1 | inih_cpp = dependency('INIReader', fallback: ['inih', 'INIReader_dep']) 2 | libevdev = dependency('libevdev') 3 | libpam = meson.get_compiler('cpp').find_library('pam') 4 | threads = dependency('threads') 5 | 6 | # Translations 7 | subdir('po') 8 | 9 | # Paths 10 | paths_h = configure_file( 11 | input: 'paths.hh.in', 12 | output: 'paths.hh', 13 | configuration: pam_module_conf_data 14 | ) 15 | 16 | pamdir = get_option('pam_dir') != '' ? get_option('pam_dir') : join_paths(get_option('prefix'), get_option('libdir'), 'security') 17 | 18 | shared_library( 19 | 'pam_howdy', 20 | 'main.cc', 21 | 'enter_device.cc', 22 | dependencies: [ 23 | libpam, 24 | inih_cpp, 25 | threads, 26 | libevdev, 27 | ], 28 | link_depends: [ 29 | paths_h, 30 | ], 31 | install: true, 32 | install_dir: pamdir, 33 | install_tag: 'pam_module', 34 | name_prefix: '' 35 | ) 36 | -------------------------------------------------------------------------------- /howdy/src/pam/optional_task.hh: -------------------------------------------------------------------------------- 1 | #ifndef OPTIONAL_TASK_H_ 2 | #define OPTIONAL_TASK_H_ 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | // A task executed only if activated. 10 | template class optional_task { 11 | std::thread thread; 12 | std::packaged_task task; 13 | std::future future; 14 | bool spawned{false}; 15 | bool is_active{false}; 16 | 17 | public: 18 | explicit optional_task(std::function func); 19 | void activate(); 20 | template 21 | auto wait(std::chrono::duration dur) -> std::future_status; 22 | auto get() -> T; 23 | void stop(bool force); 24 | ~optional_task(); 25 | }; 26 | 27 | template 28 | optional_task::optional_task(std::function func) 29 | : task(std::packaged_task(std::move(func))), future(task.get_future()) {} 30 | 31 | // Create a new thread and launch the task on it. 32 | template void optional_task::activate() { 33 | thread = std::thread(std::move(task)); 34 | spawned = true; 35 | is_active = true; 36 | } 37 | 38 | // Wait for `dur` time and return a `future` status. 39 | template 40 | template 41 | auto optional_task::wait(std::chrono::duration dur) 42 | -> std::future_status { 43 | return future.wait_for(dur); 44 | } 45 | 46 | // Get the value. 47 | // WARNING: The function should be run only if the task has successfully been 48 | // stopped. 49 | template auto optional_task::get() -> T { 50 | assert(!is_active && spawned); 51 | return future.get(); 52 | } 53 | 54 | // Stop the thread: 55 | // - if `force` is `false`, by joining the thread. 56 | // - if `force` is `true`, by cancelling the thread using `pthread_cancel`. 57 | // WARNING: This function should be used with extreme caution when `force` is 58 | // set to `true`. 59 | template void optional_task::stop(bool force) { 60 | if (!(is_active && thread.joinable()) && spawned) { 61 | is_active = false; 62 | return; 63 | } 64 | 65 | // We use pthread to cancel the thread 66 | if (force) { 67 | auto native_hd = thread.native_handle(); 68 | pthread_cancel(native_hd); 69 | } 70 | thread.join(); 71 | is_active = false; 72 | } 73 | 74 | template optional_task::~optional_task() { 75 | if (is_active && spawned) { 76 | stop(false); 77 | } 78 | } 79 | 80 | #endif // OPTIONAL_TASK_H_ 81 | -------------------------------------------------------------------------------- /howdy/src/pam/paths.hh.in: -------------------------------------------------------------------------------- 1 | const auto COMPARE_PROCESS_PATH = "@compare_script_path@"; 2 | const auto CONFIG_FILE_PATH = "@config_file_path@"; 3 | const auto USER_MODELS_DIR = "@user_models_dir@"; 4 | const auto PYTHON_EXECUTABLE_PATH = "@python_path@"; -------------------------------------------------------------------------------- /howdy/src/pam/po/LINGUAS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boltgolt/howdy/c4521c14ab8c672cadbc826a3dbec9ef95b7adb1/howdy/src/pam/po/LINGUAS -------------------------------------------------------------------------------- /howdy/src/pam/po/POTFILES: -------------------------------------------------------------------------------- 1 | main.cc -------------------------------------------------------------------------------- /howdy/src/pam/po/meson.build: -------------------------------------------------------------------------------- 1 | i18n = import('i18n') 2 | 3 | # define GETTEXT_PACKAGE and LOCALEDIR 4 | gettext_package = '-DGETTEXT_PACKAGE="@0@"'.format(meson.project_name()) 5 | localedir = '-DLOCALEDIR="@0@"'.format(get_option('prefix') / get_option('localedir')) 6 | add_project_arguments(gettext_package, localedir, language: 'cpp') 7 | 8 | i18n.gettext(meson.project_name(), 9 | args: [ '--directory=' + meson.current_source_dir(), '--keyword=S:1' ] 10 | ) -------------------------------------------------------------------------------- /howdy/src/pam/subprojects/inih.wrap: -------------------------------------------------------------------------------- 1 | [wrap-file] 2 | directory = inih-r53 3 | source_url = https://github.com/benhoyt/inih/archive/r53.tar.gz 4 | source_filename = inih-r53.tar.gz 5 | source_hash = 01b0366fdfdf6363efc070c2f856f1afa33e7a6546548bada5456ad94a516241 6 | patch_url = https://wrapdb.mesonbuild.com/v2/inih_r53-1/get_patch 7 | patch_filename = inih-r53-1-wrap.zip 8 | patch_hash = 9a53348e4ed9180a52aafc092fda080ddc70102c9fc55686990e461b22e6e1e7 9 | 10 | [provide] 11 | inih = inih_dep 12 | inireader = inireader_dep 13 | 14 | -------------------------------------------------------------------------------- /howdy/src/paths.py.in: -------------------------------------------------------------------------------- 1 | from pathlib import PurePath 2 | 3 | # Define the absolute path to the config directory 4 | config_dir = PurePath("@config_dir@") 5 | 6 | # Define the absolute path to the DLib models data directory 7 | dlib_data_dir = PurePath("@dlib_data_dir@") 8 | 9 | # Define the absolute path to the Howdy user models directory 10 | user_models_dir = PurePath("@user_models_dir@") 11 | 12 | # Define path to any howdy logs 13 | log_path = PurePath("@log_path@") 14 | 15 | # Define the absolute path to the Howdy data directory 16 | data_dir = PurePath("@data_dir@") -------------------------------------------------------------------------------- /howdy/src/paths_factory.py: -------------------------------------------------------------------------------- 1 | from pathlib import PurePath 2 | import paths 3 | 4 | models = [ 5 | "shape_predictor_5_face_landmarks.dat", 6 | "mmod_human_face_detector.dat", 7 | "dlib_face_recognition_resnet_model_v1.dat", 8 | ] 9 | 10 | 11 | def dlib_data_dir_path() -> str: 12 | return str(paths.dlib_data_dir) 13 | 14 | 15 | def shape_predictor_5_face_landmarks_path() -> str: 16 | return str(paths.dlib_data_dir / models[0]) 17 | 18 | 19 | def mmod_human_face_detector_path() -> str: 20 | return str(paths.dlib_data_dir / models[1]) 21 | 22 | 23 | def dlib_face_recognition_resnet_model_v1_path() -> str: 24 | return str(paths.dlib_data_dir / models[2]) 25 | 26 | 27 | def user_model_path(user: str) -> str: 28 | return str(paths.user_models_dir / f"{user}.dat") 29 | 30 | 31 | def config_file_path() -> str: 32 | return str(paths.config_dir / "config.ini") 33 | 34 | 35 | def snapshots_dir_path() -> PurePath: 36 | return paths.log_path / "snapshots" 37 | 38 | 39 | def snapshot_path(snapshot: str) -> str: 40 | return str(snapshots_dir_path() / snapshot) 41 | 42 | 43 | def user_models_dir_path() -> PurePath: 44 | return paths.user_models_dir 45 | 46 | 47 | def logo_path() -> str: 48 | return str(paths.data_dir / "logo.png") 49 | -------------------------------------------------------------------------------- /howdy/src/recorders/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boltgolt/howdy/c4521c14ab8c672cadbc826a3dbec9ef95b7adb1/howdy/src/recorders/__init__.py -------------------------------------------------------------------------------- /howdy/src/recorders/ffmpeg_reader.py: -------------------------------------------------------------------------------- 1 | # Class that simulates the functionality of opencv so howdy can use ffmpeg seamlessly 2 | 3 | # Import required modules 4 | import numpy 5 | import sys 6 | import re 7 | 8 | from subprocess import Popen, PIPE 9 | from cv2 import CAP_PROP_FRAME_WIDTH 10 | from cv2 import CAP_PROP_FRAME_HEIGHT 11 | from i18n import _ 12 | 13 | try: 14 | import ffmpeg 15 | except ImportError: 16 | print(_("Missing ffmpeg module, please run:")) 17 | print(" pip3 install ffmpeg-python\n") 18 | sys.exit(12) 19 | 20 | 21 | class ffmpeg_reader: 22 | """ This class was created to look as similar to the openCV features used in Howdy as possible for overall code cleanliness. """ 23 | 24 | def __init__(self, device_path, device_format, numframes=10): 25 | self.device_path = device_path 26 | self.device_format = device_format 27 | self.numframes = numframes 28 | self.video = () 29 | self.num_frames_read = 0 30 | self.height = 0 31 | self.width = 0 32 | self.init_camera = True 33 | 34 | def set(self, prop, setting): 35 | """ Setter method for height and width """ 36 | if prop == CAP_PROP_FRAME_WIDTH: 37 | self.width = setting 38 | elif prop == CAP_PROP_FRAME_HEIGHT: 39 | self.height = setting 40 | 41 | def get(self, prop): 42 | """ Getter method for height and width """ 43 | if prop == CAP_PROP_FRAME_WIDTH: 44 | return self.width 45 | elif prop == CAP_PROP_FRAME_HEIGHT: 46 | return self.height 47 | 48 | def probe(self): 49 | """ Probe the video device to get height and width info """ 50 | 51 | # Running this command on ffmpeg unfortunately returns with an exit code of 1, which is silly. 52 | # Returns an error code of 1 and this text: "/dev/video2: Immediate exit requested" 53 | args = ["ffmpeg", "-f", self.device_format, "-list_formats", "all", "-i", self.device_path] 54 | process = Popen(args, stdout=PIPE, stderr=PIPE) 55 | out, err = process.communicate() 56 | return_code = process.poll() 57 | 58 | # Worst case scenario, err will equal en empty byte string, b'', so probe will get set to [] here. 59 | regex = re.compile(r"\s\d{3,4}x\d{3,4}") 60 | probe = regex.findall(str(err.decode("utf-8"))) 61 | 62 | if not return_code == 1 or len(probe) < 1: 63 | # Could not determine the resolution from ffmpeg call. Reverting to ffmpeg.probe() 64 | probe = ffmpeg.probe(self.device_path) 65 | height = probe["streams"][0]["height"] 66 | width = probe["streams"][0]["width"] 67 | else: 68 | (height, width) = [x.strip() for x in probe[0].split("x")] 69 | 70 | # Set height and width from probe if they haven't been set already 71 | if height.isdigit() and self.get(CAP_PROP_FRAME_HEIGHT) == 0: 72 | self.set(CAP_PROP_FRAME_HEIGHT, int(height)) 73 | if width.isdigit() and self.get(CAP_PROP_FRAME_WIDTH) == 0: 74 | self.set(CAP_PROP_FRAME_WIDTH, int(width)) 75 | 76 | def record(self, numframes): 77 | """ Record a video, saving it to self.video array for processing later """ 78 | 79 | # Eensure we have set our width and height before we record, otherwise our numpy call will fail 80 | if self.get(CAP_PROP_FRAME_WIDTH) == 0 or self.get(CAP_PROP_FRAME_HEIGHT) == 0: 81 | self.probe() 82 | 83 | # Ensure num_frames_read is reset to 0 84 | self.num_frames_read = 0 85 | 86 | # Record a predetermined amount of frames from the camera 87 | stream, ret = ( 88 | ffmpeg 89 | .input(self.device_path, format=self.device_format) 90 | .output("pipe:", format="rawvideo", pix_fmt="rgb24", vframes=numframes) 91 | .run(capture_stdout=True, quiet=True) 92 | ) 93 | self.video = ( 94 | numpy 95 | .frombuffer(stream, numpy.uint8) 96 | .reshape([-1, self.width, self.height, 3]) 97 | ) 98 | 99 | def read(self): 100 | """ Read a single frame from the self.video array. Will record a video if array is empty. """ 101 | 102 | # First time we are called, we want to initialize the camera by probing it, to ensure we have height/width 103 | # and then take numframes of video to fill the buffer for faster recognition. 104 | if self.init_camera: 105 | self.init_camera = False 106 | self.video = () 107 | self.record(self.numframes) 108 | return 0, self.video 109 | 110 | # If we are called and self.video is empty, we should record self.numframes to fill the video buffer 111 | if self.video == (): 112 | self.record(self.numframes) 113 | 114 | # If we've read max frames, but still are being requested to read more, we simply record another batch. 115 | # Note, the video array is 0 based, so if numframes is 10, we must subtract 1 or run into an array index 116 | # error. 117 | if self.num_frames_read >= (self.numframes - 1): 118 | self.record(self.numframes) 119 | 120 | # Add one to num_frames_read. If we were at 0, that's fine as frame 0 is almost 100% going to be black 121 | # as the IR lights aren't fully active yet anyways. Saves us one iteration in the while loop ni add/compare.py. 122 | self.num_frames_read += 1 123 | 124 | # Return a single frame of video 125 | return 0, self.video[self.num_frames_read] 126 | 127 | def release(self): 128 | """ Empty our array. If we had a hold on the camera, we would give it back here. """ 129 | self.video = () 130 | self.num_frames_read = 0 131 | 132 | def grab(self): 133 | """ Redirect grab() to read() for compatibility """ 134 | self.read() 135 | -------------------------------------------------------------------------------- /howdy/src/recorders/pyv4l2_reader.py: -------------------------------------------------------------------------------- 1 | # Class that simulates the functionality of opencv so howdy can use v4l2 devices seamlessly 2 | 3 | # Import required modules. lib4l-dev package is also required. 4 | import fcntl 5 | import numpy 6 | import sys 7 | 8 | from recorders import v4l2 9 | from cv2 import cvtColor, COLOR_GRAY2BGR, CAP_PROP_FRAME_WIDTH, CAP_PROP_FRAME_HEIGHT 10 | from i18n import _ 11 | 12 | try: 13 | from pyv4l2.frame import Frame 14 | except ImportError: 15 | print(_("Missing pyv4l2 module, please run:")) 16 | print(" pip3 install pyv4l2\n") 17 | sys.exit(13) 18 | 19 | 20 | class pyv4l2_reader: 21 | """ This class was created to look as similar to the openCV features used in Howdy as possible for overall code cleanliness. """ 22 | 23 | # Init 24 | def __init__(self, device_name, device_format): 25 | self.device_name = device_name 26 | self.device_format = device_format 27 | self.height = 0 28 | self.width = 0 29 | self.probe() 30 | self.frame = "" 31 | 32 | def set(self, prop, setting): 33 | """ Setter method for height and width """ 34 | if prop == CAP_PROP_FRAME_WIDTH: 35 | self.width = setting 36 | elif prop == CAP_PROP_FRAME_HEIGHT: 37 | self.height = setting 38 | 39 | def get(self, prop): 40 | """ Getter method for height and width """ 41 | if prop == CAP_PROP_FRAME_WIDTH: 42 | return self.width 43 | elif prop == CAP_PROP_FRAME_HEIGHT: 44 | return self.height 45 | 46 | def probe(self): 47 | """ Probe the video device to get height and width info """ 48 | 49 | vd = open(self.device_name, 'r') 50 | fmt = v4l2.v4l2_format() 51 | fmt.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE 52 | ret = fcntl.ioctl(vd, v4l2.VIDIOC_G_FMT, fmt) 53 | vd.close() 54 | if ret == 0: 55 | height = fmt.fmt.pix.height 56 | width = fmt.fmt.pix.width 57 | else: 58 | # Could not determine the resolution from ioctl call. Reverting to slower ffmpeg.probe() method 59 | import ffmpeg 60 | probe = ffmpeg.probe(self.device_name) 61 | height = int(probe['streams'][0]['height']) 62 | width = int(probe['streams'][0]['width']) 63 | 64 | if self.get(CAP_PROP_FRAME_HEIGHT) == 0: 65 | self.set(CAP_PROP_FRAME_HEIGHT, int(height)) 66 | 67 | if self.get(CAP_PROP_FRAME_WIDTH) == 0: 68 | self.set(CAP_PROP_FRAME_WIDTH, int(width)) 69 | 70 | def record(self): 71 | """ Start recording """ 72 | self.frame = Frame(self.device_name) 73 | 74 | def grab(self): 75 | """ Read a single frame from the IR camera. """ 76 | self.read() 77 | 78 | def read(self): 79 | """ Read a single frame from the IR camera. """ 80 | 81 | if not self.frame: 82 | self.record() 83 | 84 | # Grab a raw frame from the camera 85 | frame_data = self.frame.get_frame() 86 | 87 | # Convert the raw frame_date to a numpy array 88 | img = (numpy.frombuffer(frame_data, numpy.uint8)) 89 | 90 | # Convert the numpy array to a proper grayscale image array 91 | img_bgr = cvtColor(img, COLOR_GRAY2BGR) 92 | 93 | # Convert the grayscale image array into a proper RGB style numpy array 94 | img2 = (numpy.frombuffer(img_bgr, numpy.uint8).reshape([352, 352, 3])) 95 | 96 | # Return a single frame of video 97 | return 0, img2 98 | 99 | def release(self): 100 | """ Empty our array. If we had a hold on the camera, we would give it back here. """ 101 | self.video = () 102 | self.num_frames_read = 0 103 | if self.frame: 104 | self.frame.close() 105 | -------------------------------------------------------------------------------- /howdy/src/recorders/video_capture.py: -------------------------------------------------------------------------------- 1 | # Top level class for a video capture providing simplified API's for common 2 | # functions 3 | 4 | # Import required modules 5 | import configparser 6 | import cv2 7 | import os 8 | import sys 9 | 10 | from i18n import _ 11 | 12 | # Class to provide boilerplate code to build a video recorder with the 13 | # correct settings from the config file. 14 | # 15 | # The internal recorder can be accessed with 'video_capture.internal' 16 | 17 | 18 | class VideoCapture: 19 | def __init__(self, config): 20 | """ 21 | Creates a new VideoCapture instance depending on the settings in the 22 | provided config file. 23 | 24 | Config can either be a string to the path, or a pre-setup configparser. 25 | """ 26 | 27 | # Parse config from string if needed 28 | if isinstance(config, str): 29 | self.config = configparser.ConfigParser() 30 | self.config.read(config) 31 | else: 32 | self.config = config 33 | 34 | # Check device path 35 | if not os.path.exists(self.config.get("video", "device_path")): 36 | if self.config.getboolean("video", "warn_no_device", fallback=True): 37 | print(_("Howdy could not find a camera device at the path specified in the config file.")) 38 | print(_("It is very likely that the path is not configured correctly, please edit the 'device_path' config value by running:")) 39 | print("\n\tsudo howdy config\n") 40 | sys.exit(14) 41 | 42 | # Create reader 43 | # The internal video recorder 44 | self.internal = None 45 | # The frame width 46 | self.fw = None 47 | # The frame height 48 | self.fh = None 49 | self._create_reader() 50 | 51 | # Request a frame to wake the camera up 52 | self.internal.grab() 53 | 54 | def __del__(self): 55 | """ 56 | Frees resources when destroyed 57 | """ 58 | if self is not None: 59 | try: 60 | self.internal.release() 61 | except AttributeError as err: 62 | pass 63 | 64 | def release(self): 65 | """ 66 | Release cameras 67 | """ 68 | if self is not None: 69 | self.internal.release() 70 | 71 | def read_frame(self): 72 | """ 73 | Reads a frame, returns the frame and an attempted grayscale conversion of 74 | the frame in a tuple: 75 | 76 | (frame, grayscale_frame) 77 | 78 | If the grayscale conversion fails, both items in the tuple are identical. 79 | """ 80 | 81 | # Grab a single frame of video 82 | # Don't remove ret, it doesn't work without it 83 | ret, frame = self.internal.read() 84 | if not ret: 85 | print(_("Failed to read camera specified in the 'device_path' config option, aborting")) 86 | sys.exit(14) 87 | 88 | try: 89 | # Convert from color to grayscale 90 | # First processing of frame, so frame errors show up here 91 | gsframe = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) 92 | except RuntimeError: 93 | gsframe = frame 94 | except cv2.error: 95 | print("\nAn error occurred in OpenCV\n") 96 | raise 97 | return frame, gsframe 98 | 99 | def _create_reader(self): 100 | """ 101 | Sets up the video reader instance 102 | """ 103 | recording_plugin = self.config.get("video", "recording_plugin", fallback="opencv") 104 | 105 | if recording_plugin == "ffmpeg": 106 | # Set the capture source for ffmpeg 107 | from recorders.ffmpeg_reader import ffmpeg_reader 108 | self.internal = ffmpeg_reader( 109 | self.config.get("video", "device_path"), 110 | self.config.get("video", "device_format", fallback="v4l2") 111 | ) 112 | 113 | elif recording_plugin == "pyv4l2": 114 | # Set the capture source for pyv4l2 115 | from recorders.pyv4l2_reader import pyv4l2_reader 116 | self.internal = pyv4l2_reader( 117 | self.config.get("video", "device_path"), 118 | self.config.get("video", "device_format", fallback="v4l2") 119 | ) 120 | 121 | else: 122 | # Start video capture on the IR camera through OpenCV 123 | self.internal = cv2.VideoCapture( 124 | self.config.get("video", "device_path"), 125 | cv2.CAP_V4L 126 | ) 127 | # Set the capture frame rate 128 | # Without this the first detected (and possibly lower) frame rate is used, -1 seems to select the highest 129 | # Use 0 as a fallback to avoid breaking an existing setup, new installs should default to -1 130 | self.fps = self.config.getint("video", "device_fps", fallback=0) 131 | if self.fps != 0: 132 | self.internal.set(cv2.CAP_PROP_FPS, self.fps) 133 | 134 | # Force MJPEG decoding if true 135 | if self.config.getboolean("video", "force_mjpeg", fallback=False): 136 | # Set a magic number, will enable MJPEG but is badly documentated 137 | self.internal.set(cv2.CAP_PROP_FOURCC, 1196444237) 138 | 139 | # Set the frame width and height if requested 140 | self.fw = self.config.getint("video", "frame_width", fallback=-1) 141 | self.fh = self.config.getint("video", "frame_height", fallback=-1) 142 | if self.fw != -1: 143 | self.internal.set(cv2.CAP_PROP_FRAME_WIDTH, self.fw) 144 | if self.fh != -1: 145 | self.internal.set(cv2.CAP_PROP_FRAME_HEIGHT, self.fh) 146 | -------------------------------------------------------------------------------- /howdy/src/rubberstamps/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import re 4 | 5 | from i18n import _ 6 | 7 | from importlib.machinery import SourceFileLoader 8 | 9 | 10 | class RubberStamp: 11 | """Howdy rubber stamp""" 12 | 13 | UI_TEXT = "ui_text" 14 | UI_SUBTEXT = "ui_subtext" 15 | 16 | def set_ui_text(self, text, type=None): 17 | """Convert an ui string to input howdy-gtk understands""" 18 | typedec = "M" 19 | 20 | if type == self.UI_SUBTEXT: 21 | typedec = "S" 22 | 23 | return self.send_ui_raw(typedec + "=" + text) 24 | 25 | def send_ui_raw(self, command): 26 | """Write raw command to howdy-gtk stdin""" 27 | if self.config.getboolean("debug", "verbose_stamps", fallback=False): 28 | print("Sending command to howdy-gtk: " + command) 29 | 30 | # Add a newline because the ui reads per line 31 | command += " \n" 32 | 33 | # If we're connected to the ui 34 | if self.gtk_proc: 35 | # Send the command as bytes 36 | self.gtk_proc.stdin.write(bytearray(command.encode("utf-8"))) 37 | self.gtk_proc.stdin.flush() 38 | 39 | # Write a padding line to force the command through any buffers 40 | self.gtk_proc.stdin.write(bytearray("P=_PADDING \n".encode("utf-8"))) 41 | self.gtk_proc.stdin.flush() 42 | 43 | 44 | def execute(config, gtk_proc, opencv): 45 | verbose = config.getboolean("debug", "verbose_stamps", fallback=False) 46 | dir_path = os.path.dirname(os.path.realpath(__file__)) 47 | installed_stamps = [] 48 | 49 | # Go through each file in the rubberstamp folder 50 | for filename in os.listdir(dir_path): 51 | # Remove non-readable file or directories 52 | if not os.path.isfile(dir_path + "/" + filename): 53 | continue 54 | 55 | # Remove meta files 56 | if filename in ["__init__.py", ".gitignore"]: 57 | continue 58 | 59 | # Add the found file to the list of enabled rubberstamps 60 | installed_stamps.append(filename.split(".")[0]) 61 | 62 | if verbose: print("Installed rubberstamps: " + ", ".join(installed_stamps)) 63 | 64 | # Get the rules defined in the config 65 | raw_rules = config.get("rubberstamps", "stamp_rules") 66 | rules = raw_rules.split("\n") 67 | 68 | # Go through the rules one by one 69 | for rule in rules: 70 | rule = rule.strip() 71 | 72 | if len(rule) <= 1: 73 | continue 74 | 75 | # Parse the rule with regex 76 | regex_result = re.search("^(\w+)\s+([\w\.]+)\s+([a-z]+)(.*)?$", rule, re.IGNORECASE) 77 | 78 | # Error out if the regex did not match (invalid line) 79 | if not regex_result: 80 | print(_("Error parsing rubberstamp rule: {}").format(rule)) 81 | continue 82 | 83 | type = regex_result.group(1) 84 | 85 | # Error out if the stamp name in the rule is not a file 86 | if type not in installed_stamps: 87 | print(_("Stamp not installed: {}").format(type)) 88 | continue 89 | 90 | # Load the module from file 91 | module = SourceFileLoader(type, dir_path + "/" + type + ".py").load_module() 92 | 93 | # Try to get the class with the same name 94 | try: 95 | constructor = getattr(module, type) 96 | except AttributeError: 97 | print(_("Stamp error: Class {} not found").format(type)) 98 | continue 99 | 100 | # Init the class and set common values 101 | instance = constructor() 102 | instance.verbose = verbose 103 | instance.config = config 104 | instance.gtk_proc = gtk_proc 105 | instance.opencv = opencv 106 | 107 | # Set some opensv shorthands 108 | instance.video_capture = opencv["video_capture"] 109 | instance.face_detector = opencv["face_detector"] 110 | instance.pose_predictor = opencv["pose_predictor"] 111 | instance.clahe = opencv["clahe"] 112 | 113 | # Parse and set the 2 required options for all rubberstamps 114 | instance.options = { 115 | "timeout": float(re.sub("[a-zA-Z]", "", regex_result.group(2))), 116 | "failsafe": regex_result.group(3) != "faildeadly" 117 | } 118 | 119 | # Try to get the class do declare its other config variables 120 | try: 121 | instance.declare_config() 122 | except Exception: 123 | print(_("Internal error in rubberstamp configuration declaration:")) 124 | 125 | import traceback 126 | traceback.print_exc() 127 | continue 128 | 129 | # Split the optional arguments at the end of the rule by spaces 130 | raw_options = regex_result.group(4).split() 131 | 132 | # For each of those aoptional arguments 133 | for option in raw_options: 134 | # Get the key to the left, and the value to the right of the equal sign 135 | key, value = option.split("=") 136 | 137 | # Error out if a key has been set that was not declared by the module before 138 | if key not in instance.options: 139 | print("Unknown config option for rubberstamp " + type + ": " + key) 140 | continue 141 | 142 | # Convert the argument string to an int or float if the declared option has that type 143 | if isinstance(instance.options[key], int): 144 | value = int(value) 145 | elif isinstance(instance.options[key], float): 146 | value = float(value) 147 | 148 | instance.options[key] = value 149 | 150 | if verbose: 151 | print("Stamp \"" + type + "\" options parsed:") 152 | print(instance.options) 153 | print("Executing stamp") 154 | 155 | # Make the stamp fail by default 156 | result = False 157 | 158 | # Run the stamp code 159 | try: 160 | result = instance.run() 161 | except Exception: 162 | print(_("Internal error in rubberstamp:")) 163 | 164 | import traceback 165 | traceback.print_exc() 166 | continue 167 | 168 | if verbose: print("Stamp \"" + type + "\" returned: " + str(result)) 169 | 170 | # Abort authentication if the stamp returned false 171 | if result is False: 172 | if verbose: print("Authentication aborted by rubber stamp") 173 | sys.exit(15) 174 | 175 | # This is outside the for loop, so we've run all the rules 176 | if verbose: print("All rubberstamps processed, authentication successful") 177 | 178 | # Exit with no errors 179 | sys.exit(0) 180 | -------------------------------------------------------------------------------- /howdy/src/rubberstamps/hotkey.py: -------------------------------------------------------------------------------- 1 | import time 2 | import sys 3 | 4 | from i18n import _ 5 | 6 | # Import the root rubberstamp class 7 | from rubberstamps import RubberStamp 8 | 9 | 10 | class hotkey(RubberStamp): 11 | pressed_key = "none" 12 | 13 | def declare_config(self): 14 | """Set the default values for the optional arguments""" 15 | self.options["abort_key"] = "esc" 16 | self.options["confirm_key"] = "enter" 17 | 18 | def run(self): 19 | """Wait for the user to press a hotkey""" 20 | time_left = self.options["timeout"] 21 | time_string = _("Aborting authorisation in {}") if self.options["failsafe"] else _("Authorising in {}") 22 | 23 | # Set the ui to default strings 24 | self.set_ui_text(time_string.format(int(time_left)), self.UI_TEXT) 25 | self.set_ui_text(_("Press {abort_key} to abort, {confirm_key} to authorise").format(abort_key=self.options["abort_key"], confirm_key=self.options["confirm_key"]), self.UI_SUBTEXT) 26 | 27 | # Try to import the keyboard module and tell the user to install the module if that fails 28 | try: 29 | import keyboard 30 | except Exception: 31 | print("\nMissing module for rubber stamp keyboard!") 32 | print("Please run:") 33 | print("\t pip3 install keyboard") 34 | sys.exit(1) 35 | 36 | # Register hotkeys with the kernel 37 | keyboard.add_hotkey(self.options["abort_key"], self.on_key, args=["abort"]) 38 | keyboard.add_hotkey(self.options["confirm_key"], self.on_key, args=["confirm"]) 39 | 40 | # While we have not hit our timeout yet 41 | while time_left > 0: 42 | # Remove 0.1 seconds from the timer, as that's how long we sleep 43 | time_left -= 0.1 44 | # Update the ui with the new time 45 | self.set_ui_text(time_string.format(str(int(time_left) + 1)), self.UI_TEXT) 46 | 47 | # If the abort key was pressed while the loop was sleeping 48 | if self.pressed_key == "abort": 49 | # Set the ui to confirm the abort 50 | self.set_ui_text(_("Authentication aborted"), self.UI_TEXT) 51 | self.set_ui_text("", self.UI_SUBTEXT) 52 | 53 | # Exit 54 | time.sleep(1) 55 | return False 56 | 57 | # If confirm has pressed, return that auth can continue 58 | elif self.pressed_key == "confirm": 59 | return True 60 | 61 | # If no key has been pressed, wait for a bit and check again 62 | time.sleep(0.1) 63 | 64 | # When our timeout hits, either abort or continue based on failsafe of faildeadly 65 | return not self.options["failsafe"] 66 | 67 | def on_key(self, type): 68 | """Called when the user presses a key""" 69 | self.pressed_key = type 70 | -------------------------------------------------------------------------------- /howdy/src/rubberstamps/nod.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from i18n import _ 4 | 5 | # Import the root rubberstamp class 6 | from rubberstamps import RubberStamp 7 | 8 | 9 | class nod(RubberStamp): 10 | def declare_config(self): 11 | """Set the default values for the optional arguments""" 12 | self.options["min_distance"] = 6 13 | self.options["min_directions"] = 2 14 | 15 | def run(self): 16 | """Track a users nose to see if they nod yes or no""" 17 | self.set_ui_text(_("Nod to confirm"), self.UI_TEXT) 18 | self.set_ui_text(_("Shake your head to abort"), self.UI_SUBTEXT) 19 | 20 | # Stores relative distance between the 2 eyes in the last frame 21 | # Used to calculate the distance of the nose traveled in relation to face size in the frame 22 | last_reldist = -1 23 | # Last point the nose was at 24 | last_nosepoint = {"x": -1, "y": -1} 25 | # Contains booleans recording successful nods and their directions 26 | recorded_nods = {"x": [], "y": []} 27 | 28 | starttime = time.time() 29 | 30 | # Keep running the loop while we have not hit timeout yet 31 | while time.time() < starttime + self.options["timeout"]: 32 | # Read a frame from the camera 33 | ret, frame = self.video_capture.read_frame() 34 | 35 | # Apply CLAHE to get a better picture 36 | frame = self.clahe.apply(frame) 37 | 38 | # Detect all faces in the frame 39 | face_locations = self.face_detector(frame, 1) 40 | 41 | # Only continue if exactly 1 face is visible in the frame 42 | if len(face_locations) != 1: 43 | continue 44 | 45 | # Get the position of the eyes and tip of the nose 46 | face_landmarks = self.pose_predictor(frame, face_locations[0]) 47 | 48 | # Calculate the relative distance between the 2 eyes 49 | reldist = face_landmarks.part(0).x - face_landmarks.part(2).x 50 | # Average this out with the distance found in the last frame to smooth it out 51 | avg_reldist = (last_reldist + reldist) / 2 52 | 53 | # Calculate horizontal movement (shaking head) and vertical movement (nodding) 54 | for axis in ["x", "y"]: 55 | # Get the location of the nose on the active axis 56 | nosepoint = getattr(face_landmarks.part(4), axis) 57 | 58 | # If this is the first frame set the previous values to the current ones 59 | if last_nosepoint[axis] == -1: 60 | last_nosepoint[axis] = nosepoint 61 | last_reldist = reldist 62 | 63 | mindist = self.options["min_distance"] 64 | # Get the relative movement by taking the distance traveled and dividing it by eye distance 65 | movement = (nosepoint - last_nosepoint[axis]) * 100 / max(avg_reldist, 1) 66 | 67 | # If the movement is over the minimal distance threshold 68 | if movement < -mindist or movement > mindist: 69 | # If this is the first recorded nod, add it to the array 70 | if len(recorded_nods[axis]) == 0: 71 | recorded_nods[axis].append(movement < 0) 72 | 73 | # Otherwise, only add this nod if the previous nod with in the other direction 74 | elif recorded_nods[axis][-1] != (movement < 0): 75 | recorded_nods[axis].append(movement < 0) 76 | 77 | # Check if we have nodded enough on this axis 78 | if len(recorded_nods[axis]) >= self.options["min_directions"]: 79 | # If nodded yes, show confirmation in ui 80 | if (axis == "y"): 81 | self.set_ui_text(_("Confirmed authentication"), self.UI_TEXT) 82 | # If shaken no, show abort message 83 | else: 84 | self.set_ui_text(_("Aborted authentication"), self.UI_TEXT) 85 | 86 | # Remove subtext 87 | self.set_ui_text("", self.UI_SUBTEXT) 88 | 89 | # Return true for nodding yes and false for shaking no 90 | time.sleep(0.8) 91 | return axis == "y" 92 | 93 | # Save the relative distance and the nosepoint for next loop 94 | last_reldist = reldist 95 | last_nosepoint[axis] = nosepoint 96 | 97 | # We've fallen out of the loop, so timeout has been hit 98 | return not self.options["failsafe"] 99 | -------------------------------------------------------------------------------- /howdy/src/snapshot.py: -------------------------------------------------------------------------------- 1 | # Create and save snapshots of auth attempts 2 | 3 | # Import modules 4 | import cv2 5 | import os 6 | from datetime import timezone, datetime 7 | import numpy as np 8 | import paths_factory 9 | 10 | 11 | def generate(frames, text_lines): 12 | """Generate a snapshot from given frames""" 13 | 14 | # Don't execute if no frames were given 15 | if len(frames) == 0: 16 | return 17 | 18 | # Get frame dimensions 19 | frame_height, frame_width, cc = frames[0].shape 20 | # Spread the given frames out horizontally 21 | snap = np.concatenate(frames, axis=1) 22 | 23 | # Create colors 24 | pad_color = [44, 44, 44] 25 | text_color = [255, 255, 255] 26 | 27 | # Add a gray square at the bottom of the image 28 | snap = cv2.copyMakeBorder(snap, 0, len(text_lines) * 20 + 40, 0, 0, cv2.BORDER_CONSTANT, value=pad_color) 29 | 30 | # Add the Howdy logo if there's space to do so 31 | if len(frames) > 1: 32 | # Load the logo from file 33 | logo = cv2.imread(paths_factory.logo_path()) 34 | # Calculate the position of the logo 35 | logo_y = frame_height + 20 36 | logo_x = frame_width * len(frames) - 210 37 | 38 | # Overlay the logo on top of the image 39 | snap[logo_y:logo_y+57, logo_x:logo_x+180] = logo 40 | 41 | # Go through each line 42 | line_number = 0 43 | for line in text_lines: 44 | # Calculate how far the line should be from the top 45 | padding_top = frame_height + 30 + (line_number * 20) 46 | # Print the line onto the image 47 | cv2.putText(snap, line, (30, padding_top), cv2.FONT_HERSHEY_SIMPLEX, .4, text_color, 0, cv2.LINE_AA) 48 | 49 | line_number += 1 50 | 51 | # Made sure a snapshot folder exist 52 | if not os.path.exists(paths_factory.snapshots_dir_path()): 53 | os.makedirs(paths_factory.snapshots_dir_path()) 54 | 55 | # Generate a filename based on the current time 56 | filename = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.jpg") 57 | filepath = paths_factory.snapshot_path(filename) 58 | # Write the image to that file 59 | cv2.imwrite(filepath, snap) 60 | 61 | # Return the saved file location 62 | return filepath 63 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('howdy', 'cpp', license: 'MIT', version: 'beta', meson_version: '>= 0.64.0') 2 | 3 | dlibdatadir = get_option('dlib_data_dir') != '' ? get_option('dlib_data_dir') : join_paths(get_option('prefix'), get_option('datadir'), 'dlib-data') 4 | confdir = get_option('config_dir') != '' ? get_option('config_dir') : join_paths(get_option('prefix'), get_option('sysconfdir'), 'howdy') 5 | usermodelsdir = get_option('user_models_dir') != '' ? get_option('user_models_dir') : join_paths(confdir, 'models') 6 | logpath = get_option('log_path') 7 | pythonpath = get_option('python_path') 8 | 9 | config_path = join_paths(confdir, 'config.ini') 10 | 11 | paths_dict = { 12 | 'config_dir': confdir, 13 | 'dlib_data_dir': dlibdatadir, 14 | 'user_models_dir': usermodelsdir, 15 | 'log_path': logpath, 16 | 'python_path': pythonpath 17 | } 18 | 19 | # We need to keep this order beause howdy-gtk defines the gtk script path which is used later in howdy 20 | subdir('howdy-gtk') 21 | subdir('howdy') -------------------------------------------------------------------------------- /meson.options: -------------------------------------------------------------------------------- 1 | option('pam_dir', type: 'string', value: '', description: 'Set the pam_howdy destination directory') 2 | #option('fetch_dlib_data', type: 'boolean', value: false, description: 'Download dlib data files') 3 | option('config_dir', type: 'string', value: '', description: 'Set the howdy config directory') 4 | option('dlib_data_dir', type: 'string', value: '', description: 'Set the dlib data directory') 5 | option('user_models_dir', type: 'string', value: '', description: 'Set the user models directory') 6 | option('log_path', type: 'string', value: '/var/log/howdy', description: 'Set the log file path') 7 | option('install_in_site_packages', type: 'boolean', value: false, description: 'Install howdy python files in site packages') 8 | option('py_sources_dir', type: 'string', value: '', description: 'Set the python sources directory') 9 | option('install_pam_config', type: 'boolean', value: false, description: 'Install pam config file (for Debian/Ubuntu)') 10 | option('python_path', type: 'string', value: '/usr/bin/python', description: 'Set the path to the python executable') 11 | option('with_polkit', type: 'boolean', value: false, description: 'Install polkit policy config file') 12 | -------------------------------------------------------------------------------- /meson_options.txt: -------------------------------------------------------------------------------- 1 | meson.options --------------------------------------------------------------------------------