commit
1d4308cc0a
16 changed files with 725 additions and 145 deletions
45
README.md
45
README.md
|
|
@ -1,46 +1,41 @@
|
||||||
# Howdy for Ubuntu
|
# Howdy for Ubuntu
|
||||||
|
|
||||||
Windows Hello™ style authentication for Ubuntu. Use your build in IR emitters and camera in combination with face recognition to prove who you are.
|
Windows Hello™ style authentication for Ubuntu. Use your built-in IR emitters and camera in combination with face recognition to prove who you are.
|
||||||
|
|
||||||
|
Using the central authentication system in Linux (PAM), this works everywhere you would otherwise need your password: Login, lock screen, sudo, su, etc.
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
First we need to install pam-python, fswebcam and OpenCV from the Ubuntu repositories:
|
Run the installer by pasting (`ctrl+shift+V`) the following command into the terminal:
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo apt install libpam-python fswebcam libopencv-dev python-opencv
|
wget -O /tmp/howdy_install.py https://raw.githubusercontent.com/Boltgolt/howdy/master/installer.py && sudo python3 /tmp/howdy_install.py
|
||||||
```
|
```
|
||||||
|
|
||||||
After that, install the face_recognition python module. There's an excellent step by step guide on how to do this on [its github page](https://github.com/ageitgey/face_recognition#installation).
|
This will guide you through the installation. When that's done run `howdy USER add` and replace `USER` with your username to add a face model.
|
||||||
|
|
||||||
In the root of your cloned repo is a file called `config.ini`. The `device_id` variable in this file is important, make sure it is the IR camera and not your normal webcam.
|
|
||||||
|
|
||||||
Now it's time to let Howdy learn your face. The learn.py script will make 3 models of your face and store them as an encoded set in the `models` folder. To run the script, open a terminal, navigate to this repository and run:
|
|
||||||
|
|
||||||
```
|
|
||||||
python3 learn.py
|
|
||||||
```
|
|
||||||
|
|
||||||
The script should guide you through the process.
|
|
||||||
|
|
||||||
Finally we need to tell PAM that there's a new module installed. Open `/etc/pam.d/common-auth` as root (`sudo nano /etc/pam.d/common-auth`) and add the following line to the top of the file:
|
|
||||||
|
|
||||||
```
|
|
||||||
auth sufficient pam_python.so /path/to/pam.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace the final argument with the full path to pam.py in this repository. The `sufficient` control tells PAM that Howdy is enough to authenticate the user, but if it fails we can fall back on more traditional methods.
|
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
**Note:** The build of dlib can hang on 100% for over a minute, give it time.
|
||||||
|
|
||||||
|
### Command line
|
||||||
|
|
||||||
|
The installer adds a `howdy` command to manage face models for the current user. Use `howdy help` to list the available options.
|
||||||
|
|
||||||
### Troubleshooting
|
### Troubleshooting
|
||||||
|
|
||||||
Any errors in the script itself 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.
|
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.
|
||||||
|
|
||||||
|
If you encounter an error that hasn't been reported yet, don't be afraid to open a new issue.
|
||||||
|
|
||||||
|
### Uninstalling
|
||||||
|
|
||||||
|
There is an uninstaller available, run `sudo python3 /lib/security/howdy/uninstall.py` to remove Howdy from your system.
|
||||||
|
|
||||||
### A note on security
|
### A note on security
|
||||||
|
|
||||||
This script 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 well-printed photo of you could be enough to do it.
|
This script 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 well-printed photo of you could be enough to do it.
|
||||||
|
|
||||||
To minimize the chance of this script being compromised, it's recommend to store this repo in `/etc/pam.d` and to make it read only.
|
To minimize the chance of this script being compromised, it's recommend to leave this repo in /lib/security and to keep it read only.
|
||||||
|
|
||||||
DO NOT USE THIS SCRIPT AS THE SOLE AUTHENTICATION METHOD FOR YOUR SYSTEM.
|
DO NOT USE HOWDY AS THE SOLE AUTHENTICATION METHOD FOR YOUR SYSTEM.
|
||||||
|
|
|
||||||
15
autocomplete.sh
Normal file
15
autocomplete.sh
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Autocomplete file run in bash
|
||||||
|
# Will sugest arguments on tab
|
||||||
|
|
||||||
|
_howdy() {
|
||||||
|
local cur prev opts
|
||||||
|
COMPREPLY=()
|
||||||
|
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||||
|
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||||
|
opts="help list add remove clear"
|
||||||
|
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
complete -F _howdy howdy
|
||||||
41
cli.py
Executable file
41
cli.py
Executable file
|
|
@ -0,0 +1,41 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# CLI directly called by running the howdy command
|
||||||
|
|
||||||
|
# Import required modules
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Check if the minimum of 3 arugemnts has been met and print help otherwise
|
||||||
|
if (len(sys.argv) < 3):
|
||||||
|
print("Howdy IR face recognition help")
|
||||||
|
import cli.help
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
# The command given
|
||||||
|
cmd = sys.argv[2]
|
||||||
|
|
||||||
|
# Requre sudo for comamnds that need root rights to read the model files
|
||||||
|
if cmd in ["list", "add", "remove", "clear"] and os.getenv("SUDO_USER") is None:
|
||||||
|
print("Please run this command with sudo")
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
# Call the right files for the given command
|
||||||
|
if cmd == "list":
|
||||||
|
import cli.list
|
||||||
|
elif cmd == "help":
|
||||||
|
print("Howdy IR face recognition")
|
||||||
|
import cli.help
|
||||||
|
elif cmd == "add":
|
||||||
|
import cli.add
|
||||||
|
elif cmd == "remove":
|
||||||
|
import cli.remove
|
||||||
|
elif cmd == "clear":
|
||||||
|
import cli.clear
|
||||||
|
else:
|
||||||
|
# If the comand is invalid, check if the user hasn't swapped the username and command
|
||||||
|
if sys.argv[1] in ["list", "add", "remove", "clear", "help"]:
|
||||||
|
print("Usage: howdy <user> <command>")
|
||||||
|
else:
|
||||||
|
print('Unknown command "' + cmd + '"')
|
||||||
|
import cli.help
|
||||||
0
cli/__init__.py
Normal file
0
cli/__init__.py
Normal file
|
|
@ -1,24 +1,35 @@
|
||||||
# Save the face of the user in encoded form
|
# Save the face of the user in encoded form
|
||||||
|
|
||||||
# Import required modules
|
# Import required modules
|
||||||
import face_recognition
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
|
||||||
# Import config and extra functions
|
|
||||||
import configparser
|
import configparser
|
||||||
import utils
|
|
||||||
|
|
||||||
|
# Try to import face_recognition and give a nice error if we can't
|
||||||
|
# Add should be the first point where import issues show up
|
||||||
|
try:
|
||||||
|
import face_recognition
|
||||||
|
except ImportError as err:
|
||||||
|
print(err)
|
||||||
|
|
||||||
|
print("\nCan't import the face_recognition module, check the output of")
|
||||||
|
print("pip3 show face_recognition")
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
# Get the absolute path to the current file
|
||||||
|
path = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
# Read config from disk
|
# Read config from disk
|
||||||
config = configparser.ConfigParser()
|
config = configparser.ConfigParser()
|
||||||
config.read(os.path.dirname(os.path.abspath(__file__)) + "/config.ini")
|
config.read(path + "/../config.ini")
|
||||||
|
|
||||||
def captureFrame(delay):
|
def captureFrame(delay):
|
||||||
"""Capture and encode 1 frame of video"""
|
"""Capture and encode 1 frame of video"""
|
||||||
global encodings
|
global insert_model
|
||||||
|
|
||||||
# Call fswebcam to save a frame to /tmp with a set delay
|
# Call fswebcam to save a frame to /tmp with a set delay
|
||||||
exit_code = subprocess.call(["fswebcam", "-S", str(delay), "--no-banner", "-d", "/dev/video" + str(config.get("video", "device_id")), tmp_file])
|
exit_code = subprocess.call(["fswebcam", "-S", str(delay), "--no-banner", "-d", "/dev/video" + str(config.get("video", "device_id")), tmp_file])
|
||||||
|
|
@ -54,36 +65,53 @@ def captureFrame(delay):
|
||||||
for point in enc[0]:
|
for point in enc[0]:
|
||||||
clean_enc.append(point)
|
clean_enc.append(point)
|
||||||
|
|
||||||
encodings.append(clean_enc)
|
insert_model["data"].append(clean_enc)
|
||||||
|
|
||||||
# The current user
|
# The current user
|
||||||
user = os.environ.get("USER")
|
user = sys.argv[1]
|
||||||
# The name of the tmp frame file to user
|
# The name of the tmp frame file to user
|
||||||
tmp_file = "/tmp/howdy_" + user + ".jpg"
|
tmp_file = "/tmp/howdy_" + user + ".jpg"
|
||||||
# The permanent file to store the encoded model in
|
# The permanent file to store the encoded model in
|
||||||
enc_file = "./models/" + user + ".dat"
|
enc_file = path + "/../models/" + user + ".dat"
|
||||||
# Known encodings
|
# Known encodings
|
||||||
encodings = []
|
encodings = []
|
||||||
|
|
||||||
# Make the ./models folder if it doesn't already exist
|
# Make the ./models folder if it doesn't already exist
|
||||||
if not os.path.exists("models"):
|
if not os.path.exists(path + "/../models"):
|
||||||
print("No face model folder found, creating one")
|
print("No face model folder found, creating one")
|
||||||
os.makedirs("models")
|
os.makedirs(path + "/../models")
|
||||||
|
|
||||||
# To try read a premade encodings file if it exists
|
# To try read a premade encodings file if it exists
|
||||||
try:
|
try:
|
||||||
encodings = json.load(open(enc_file))
|
encodings = json.load(open(enc_file))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
encodings = False
|
|
||||||
|
|
||||||
# If a file does exist, ask the user what needs to be done
|
|
||||||
if encodings != False:
|
|
||||||
encodings = utils.print_menu(encodings)
|
|
||||||
else:
|
|
||||||
encodings = []
|
encodings = []
|
||||||
|
|
||||||
print("\nLearning face for the user account " + user)
|
print("Adding face model for the user account " + user)
|
||||||
print("Please look straight into the camera for 5 seconds")
|
|
||||||
|
# Set the default label
|
||||||
|
label = "Initial model"
|
||||||
|
|
||||||
|
# If models already exist, set that default label
|
||||||
|
if len(encodings) > 0:
|
||||||
|
label = "Model #" + str(len(encodings) + 1)
|
||||||
|
|
||||||
|
# Ask the user for a custom label
|
||||||
|
label_in = input("Enter a label for this new model [" + label + "]: ")
|
||||||
|
|
||||||
|
# Set the custom label (if any) and limit it to 24 characters
|
||||||
|
if label_in != "":
|
||||||
|
label = label_in[:24]
|
||||||
|
|
||||||
|
# Prepare the metadata for insertion
|
||||||
|
insert_model = {
|
||||||
|
"time": int(time.time()),
|
||||||
|
"label": label,
|
||||||
|
"id": len(encodings),
|
||||||
|
"data": []
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\nPlease look straight into the camera for 5 seconds")
|
||||||
|
|
||||||
# Give the user time to read
|
# Give the user time to read
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
@ -93,6 +121,9 @@ for delay in [30, 6, 0]:
|
||||||
time.sleep(.3)
|
time.sleep(.3)
|
||||||
captureFrame(delay)
|
captureFrame(delay)
|
||||||
|
|
||||||
|
# Insert full object into the list
|
||||||
|
encodings.append(insert_model)
|
||||||
|
|
||||||
# Save the new encodings to disk
|
# Save the new encodings to disk
|
||||||
with open(enc_file, "w") as datafile:
|
with open(enc_file, "w") as datafile:
|
||||||
json.dump(encodings, datafile)
|
json.dump(encodings, datafile)
|
||||||
33
cli/clear.py
Normal file
33
cli/clear.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Clear all models by deleting the whole file
|
||||||
|
|
||||||
|
# Import required modules
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Get the full path to this file
|
||||||
|
path = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
# Get the passed user
|
||||||
|
user = sys.argv[1]
|
||||||
|
|
||||||
|
# Check if the models folder is there
|
||||||
|
if not os.path.exists(path + "/../models"):
|
||||||
|
print("No models created yet, can't clear them if they don't exist")
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
# Check if the user has a models file to delete
|
||||||
|
if not os.path.isfile(path + "/../models/" + user + ".dat"):
|
||||||
|
print(user + " has no models or they have been cleared already")
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
# Double check with the user
|
||||||
|
print("This will clear all models for " + user)
|
||||||
|
ans = input("Do you want to continue [y/N]: ")
|
||||||
|
|
||||||
|
# Abort if they don't answer y or Y
|
||||||
|
if (ans.lower() != "y"):
|
||||||
|
print('\nInerpeting as a "NO"')
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
# Delete otherwise
|
||||||
|
os.remove(path + "/../models/" + user + ".dat")
|
||||||
|
print("\nModels cleared")
|
||||||
16
cli/help.py
Normal file
16
cli/help.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Prints a simple help page for the CLI
|
||||||
|
|
||||||
|
print("""
|
||||||
|
Usage:
|
||||||
|
howdy <user> <command> [argument]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
help Show this help page
|
||||||
|
list List all saved face models for the current user
|
||||||
|
add Add a new face model for the current user
|
||||||
|
remove [id] Remove a specific model
|
||||||
|
clear Remove all face models for the current user
|
||||||
|
|
||||||
|
For support please visit
|
||||||
|
https://github.com/Boltgolt/howdy\
|
||||||
|
""")
|
||||||
46
cli/list.py
Normal file
46
cli/list.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# List all models for a user
|
||||||
|
|
||||||
|
# Import required modules
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Get the absolute path and the username
|
||||||
|
path = os.path.dirname(os.path.realpath(__file__)) + "/.."
|
||||||
|
user = sys.argv[1]
|
||||||
|
|
||||||
|
# Check if the models file has been created yet
|
||||||
|
if not os.path.exists(path + "/models"):
|
||||||
|
print("Face models have not been initialized yet, please run:")
|
||||||
|
print("\n\thowdy " + user + " add\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Path to the models file
|
||||||
|
enc_file = path + "/models/" + user + ".dat"
|
||||||
|
|
||||||
|
# Try to load the models file and abort if the user does not have it yet
|
||||||
|
try:
|
||||||
|
encodings = json.load(open(enc_file))
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("No face model known for the user " + user + ", please run:")
|
||||||
|
print("\n\thowdy " + user + " add\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Print a header
|
||||||
|
print("Known face models for " + user + ":")
|
||||||
|
print("\n\t\033[1;29mID Date Label\033[0m")
|
||||||
|
|
||||||
|
# Loop through all encodings and print info about them
|
||||||
|
for enc in encodings:
|
||||||
|
# Start with a tab and print the id
|
||||||
|
print("\t" + str(enc["id"]), end="")
|
||||||
|
# Print padding spaces after the id
|
||||||
|
print((4 - len(str(enc["id"]))) * " ", end="")
|
||||||
|
# Format the time as ISO in the local timezone
|
||||||
|
print(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(enc["time"])), end="")
|
||||||
|
# End with the label
|
||||||
|
print(" " + enc["label"])
|
||||||
|
|
||||||
|
# Add a closing enter
|
||||||
|
print()
|
||||||
78
cli/remove.py
Normal file
78
cli/remove.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Remove a encoding from the models file
|
||||||
|
|
||||||
|
# Import required modules
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Get the absolute path and the username
|
||||||
|
path = os.path.dirname(os.path.realpath(__file__)) + "/.."
|
||||||
|
user = sys.argv[1]
|
||||||
|
|
||||||
|
# Check if enough arguments have been passed
|
||||||
|
if len(sys.argv) == 3:
|
||||||
|
print("Please add the ID of the model to remove as an argument")
|
||||||
|
print("You can find the IDs by running:")
|
||||||
|
print("\n\thowdy " + user + " list\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Check if the models file has been created yet
|
||||||
|
if not os.path.exists(path + "/models"):
|
||||||
|
print("Face models have not been initialized yet, please run:")
|
||||||
|
print("\n\thowdy " + user + " add\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Path to the models file
|
||||||
|
enc_file = path + "/models/" + user + ".dat"
|
||||||
|
|
||||||
|
# Try to load the models file and abort if the user does not have it yet
|
||||||
|
try:
|
||||||
|
encodings = json.load(open(enc_file))
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("No face model known for the user " + user + ", please run:")
|
||||||
|
print("\n\thowdy " + user + " add\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Tracks if a encoding with that id has been found
|
||||||
|
found = False
|
||||||
|
|
||||||
|
# Loop though all encodings and check if they match the argument
|
||||||
|
for enc in encodings:
|
||||||
|
if str(enc["id"]) == sys.argv[3]:
|
||||||
|
# Double check with the user
|
||||||
|
print('This will remove the model called "' + enc["label"] + '" for ' + user)
|
||||||
|
ans = input("Do you want to continue [y/N]: ")
|
||||||
|
|
||||||
|
# Abort if the answer isn't yes
|
||||||
|
if (ans.lower() != "y"):
|
||||||
|
print('\nInerpeting as a "NO"')
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
# Mark as found and print an enter
|
||||||
|
found = True
|
||||||
|
print()
|
||||||
|
break
|
||||||
|
|
||||||
|
# Abort if no matching id was found
|
||||||
|
if not found:
|
||||||
|
print("No model with ID " + sys.argv[3] + " exists for " + user)
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
# Remove the entire file if this encoding is the only one
|
||||||
|
if len(encodings) == 1:
|
||||||
|
os.remove(path + "/models/" + user + ".dat")
|
||||||
|
print("Removed last model, howdy disabled for user")
|
||||||
|
else:
|
||||||
|
# A place holder to contain the encodings that will remain
|
||||||
|
new_encodings = []
|
||||||
|
|
||||||
|
# Loop though all encodin and only add thos that don't need to be removed
|
||||||
|
for enc in encodings:
|
||||||
|
if str(enc["id"]) != sys.argv[3]:
|
||||||
|
new_encodings.append(enc)
|
||||||
|
|
||||||
|
# Save this new set to disk
|
||||||
|
with open(enc_file, "w") as datafile:
|
||||||
|
json.dump(new_encodings, datafile)
|
||||||
|
|
||||||
|
print("Removed model " + sys.argv[3])
|
||||||
69
compair.py
69
compair.py
|
|
@ -1,69 +0,0 @@
|
||||||
# Compair incomming video with known faces
|
|
||||||
# Running in a local python instance to get around PATH issues
|
|
||||||
|
|
||||||
# Import required modules
|
|
||||||
import face_recognition
|
|
||||||
import cv2
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import configparser
|
|
||||||
|
|
||||||
# Read config from disk
|
|
||||||
config = configparser.ConfigParser()
|
|
||||||
config.read(os.path.dirname(os.path.abspath(__file__)) + "/config.ini")
|
|
||||||
|
|
||||||
def stop(status):
|
|
||||||
"""Stop the execution and close video stream"""
|
|
||||||
video_capture.release()
|
|
||||||
sys.exit(status)
|
|
||||||
|
|
||||||
# Make sure we were given an username to tast against
|
|
||||||
try:
|
|
||||||
if not isinstance(sys.argv[1], str):
|
|
||||||
sys.exit(1)
|
|
||||||
except IndexError:
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# The username of the authenticating user
|
|
||||||
user = sys.argv[1]
|
|
||||||
# List of known faces, encoded by face_recognition
|
|
||||||
encodings = []
|
|
||||||
# Amount of frames already matched
|
|
||||||
tries = 0
|
|
||||||
|
|
||||||
# Try to load the face model from the models folder
|
|
||||||
try:
|
|
||||||
encodings = json.load(open(os.path.dirname(os.path.abspath(__file__)) + "/models/" + user + ".dat"))
|
|
||||||
except FileNotFoundError:
|
|
||||||
sys.exit(10)
|
|
||||||
|
|
||||||
# Verify that we have a valid model file
|
|
||||||
if len(encodings) < 3:
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Start video capture on the IR camera
|
|
||||||
video_capture = cv2.VideoCapture(int(config.get("video", "device_id")))
|
|
||||||
|
|
||||||
while True:
|
|
||||||
# Grab a single frame of video
|
|
||||||
ret, frame = video_capture.read()
|
|
||||||
|
|
||||||
# Get all faces from that frame as encodings
|
|
||||||
face_encodings = face_recognition.face_encodings(frame)
|
|
||||||
|
|
||||||
# Loop through each face
|
|
||||||
for face_encoding in face_encodings:
|
|
||||||
# Match this found face against a known face
|
|
||||||
matches = face_recognition.face_distance(encodings, face_encoding)
|
|
||||||
|
|
||||||
# Check if any match is certain enough to be the user we're looking for
|
|
||||||
for match in matches:
|
|
||||||
if match < int(config.get("video", "certainty")) and match > 0:
|
|
||||||
stop(0)
|
|
||||||
|
|
||||||
# Stop if we've exceded the maximum retry count
|
|
||||||
if tries > int(config.get("video", "frame_count")):
|
|
||||||
stop(11)
|
|
||||||
|
|
||||||
tries += 1
|
|
||||||
140
compare.py
Normal file
140
compare.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
# Compare incomming video with known faces
|
||||||
|
# Running in a local python instance to get around PATH issues
|
||||||
|
|
||||||
|
# Import required modules
|
||||||
|
import cv2
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import math
|
||||||
|
import configparser
|
||||||
|
|
||||||
|
# Start timing
|
||||||
|
timings = [time.time()]
|
||||||
|
|
||||||
|
# Read config from disk
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read(os.path.dirname(os.path.abspath(__file__)) + "/config.ini")
|
||||||
|
|
||||||
|
def stop(status):
|
||||||
|
"""Stop the execution and close video stream"""
|
||||||
|
video_capture.release()
|
||||||
|
sys.exit(status)
|
||||||
|
|
||||||
|
# Make sure we were given an username to tast against
|
||||||
|
try:
|
||||||
|
if not isinstance(sys.argv[1], str):
|
||||||
|
sys.exit(1)
|
||||||
|
except IndexError:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# The username of the authenticating user
|
||||||
|
user = sys.argv[1]
|
||||||
|
# The model file contents
|
||||||
|
models = []
|
||||||
|
# Encoded face models
|
||||||
|
encodings = []
|
||||||
|
# Amount of frames already matched
|
||||||
|
tries = 0
|
||||||
|
|
||||||
|
# Try to load the face model from the models folder
|
||||||
|
try:
|
||||||
|
models = json.load(open(os.path.dirname(os.path.abspath(__file__)) + "/models/" + user + ".dat"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
sys.exit(10)
|
||||||
|
|
||||||
|
# Check if the file contains a model
|
||||||
|
if len(models) < 1:
|
||||||
|
sys.exit(10)
|
||||||
|
|
||||||
|
# Put all models together into 1 array
|
||||||
|
for model in models:
|
||||||
|
encodings += model["data"]
|
||||||
|
|
||||||
|
# Import face recognition, takes some time
|
||||||
|
timings.append(time.time())
|
||||||
|
import face_recognition
|
||||||
|
timings.append(time.time())
|
||||||
|
|
||||||
|
# Start video capture on the IR camera
|
||||||
|
video_capture = cv2.VideoCapture(int(config.get("video", "device_id")))
|
||||||
|
timings.append(time.time())
|
||||||
|
|
||||||
|
# Fetch the max frame height
|
||||||
|
max_height = int(config.get("video", "max_height"))
|
||||||
|
|
||||||
|
# Start the read loop
|
||||||
|
frames = 0
|
||||||
|
while True:
|
||||||
|
frames += 1
|
||||||
|
|
||||||
|
# Grab a single frame of video
|
||||||
|
# Don't remove ret, it doesn't work without it
|
||||||
|
ret, frame = video_capture.read()
|
||||||
|
|
||||||
|
# Get the height and with of the image
|
||||||
|
height, width = frame.shape[:2]
|
||||||
|
|
||||||
|
# If the hight is too high
|
||||||
|
if max_height < height:
|
||||||
|
# Calculate the amount the image has to shrink
|
||||||
|
scaling_factor = max_height / float(height)
|
||||||
|
# Apply that factor to the frame
|
||||||
|
frame = cv2.resize(frame, None, fx=scaling_factor, fy=scaling_factor, interpolation=cv2.INTER_AREA)
|
||||||
|
|
||||||
|
# Save the new size for diagnostics
|
||||||
|
scale_height, scale_width = frame.shape[:2]
|
||||||
|
|
||||||
|
# Convert from BGR to RGB
|
||||||
|
frame = frame[:, :, ::-1]
|
||||||
|
|
||||||
|
# Get all faces from that frame as encodings
|
||||||
|
face_encodings = face_recognition.face_encodings(frame)
|
||||||
|
|
||||||
|
# Loop through each face
|
||||||
|
for face_encoding in face_encodings:
|
||||||
|
# Match this found face against a known face
|
||||||
|
matches = face_recognition.face_distance(encodings, face_encoding)
|
||||||
|
|
||||||
|
# Check if any match is certain enough to be the user we're looking for
|
||||||
|
match_index = 0
|
||||||
|
for match in matches:
|
||||||
|
match_index += 1
|
||||||
|
|
||||||
|
# Try to find a match that's confident enough
|
||||||
|
if match * 10 < float(config.get("video", "certainty")) and match > 0:
|
||||||
|
timings.append(time.time())
|
||||||
|
|
||||||
|
# If set to true in the config, print debug text
|
||||||
|
if config.get("debug", "end_report") == "true":
|
||||||
|
def print_timing(label, offset):
|
||||||
|
"""Helper function to print a timing from the list"""
|
||||||
|
print(" " + label + ": " + str(round((timings[1 + offset] - timings[offset]) * 1000)) + "ms")
|
||||||
|
|
||||||
|
print("Time spend")
|
||||||
|
print_timing("Starting up", 0)
|
||||||
|
print_timing("Importing face_recognition", 1)
|
||||||
|
print_timing("Opening the camera", 2)
|
||||||
|
print_timing("Searching for known face", 3)
|
||||||
|
|
||||||
|
print("\nResolution")
|
||||||
|
print(" Native: " + str(height) + "x" + str(width))
|
||||||
|
print(" Used: " + str(scale_height) + "x" + str(scale_width))
|
||||||
|
|
||||||
|
print("\nFrames searched: " + str(frames) + " (" + str(round(float(frames) / (timings[4] - timings[2]), 2)) + " fps)")
|
||||||
|
print("Certainty of winning frame: " + str(round(match * 10, 3)))
|
||||||
|
|
||||||
|
exposures = ["long", "medium", "short"]
|
||||||
|
model_id = math.floor(float(match_index) / 3)
|
||||||
|
|
||||||
|
print("Winning model: " + str(model_id) + " (\"" + models[model_id]["label"] + "\") using " + exposures[match_index % 3] + " exposure\n")
|
||||||
|
|
||||||
|
# End peacegully
|
||||||
|
stop(0)
|
||||||
|
|
||||||
|
# Stop if we've exceded the maximum retry count
|
||||||
|
if time.time() - timings[3] > int(config.get("video", "timout")):
|
||||||
|
stop(11)
|
||||||
|
|
||||||
|
tries += 1
|
||||||
16
config.ini
16
config.ini
|
|
@ -9,11 +9,19 @@ suppress_unknown = false
|
||||||
[video]
|
[video]
|
||||||
# The certainty of the detected face belonging to the user of the account
|
# The certainty of the detected face belonging to the user of the account
|
||||||
# On a scale from 1 to 10, values above 5 are not recommended
|
# On a scale from 1 to 10, values above 5 are not recommended
|
||||||
certainty = 3
|
certainty = 3.5
|
||||||
|
|
||||||
# The number of frames to capture and to process before timing out
|
# The number of seconds to search before timing out
|
||||||
frame_count = 30
|
timout = 4
|
||||||
|
|
||||||
# The /dev/videoX id to capture frames from
|
# The /dev/videoX id to capture frames from
|
||||||
# In my case, video0 is the normal camera and video1 is the IR version
|
# Should be set automatically by the installer
|
||||||
device_id = 1
|
device_id = 1
|
||||||
|
|
||||||
|
# Scale down the video feed to this maximum height
|
||||||
|
# Speeds up face recognition but can make it less precise
|
||||||
|
max_height = 320
|
||||||
|
|
||||||
|
[debug]
|
||||||
|
# Show a short but detailed diagnostic report in console
|
||||||
|
end_report = false
|
||||||
|
|
|
||||||
235
installer.py
Normal file
235
installer.py
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
# Installation script to install howdy
|
||||||
|
# Runs completely independent of the others
|
||||||
|
|
||||||
|
# Import required modules
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import fileinput
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
def log(text):
|
||||||
|
"""Print a nicely formatted line to stdout"""
|
||||||
|
print("\n>>> \033[32m" + text + "\033[0m\n")
|
||||||
|
|
||||||
|
def handleStatus(status):
|
||||||
|
"""Abort if a command fails"""
|
||||||
|
if (status != 0):
|
||||||
|
print("\033[31mError while running last command\033[0m")
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
# Check if we're running as root
|
||||||
|
user = os.getenv("SUDO_USER")
|
||||||
|
if user is None:
|
||||||
|
print("Please run this script as a sudo user")
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
# Print some nice intro text
|
||||||
|
print("\n\033[33m HOWDY INSTALLER FOR UBUNTU\033[0m")
|
||||||
|
print(" Version 1, 2016/02/05\n")
|
||||||
|
|
||||||
|
# Let it sink in
|
||||||
|
time.sleep(.5)
|
||||||
|
log("Installing required apt packages")
|
||||||
|
|
||||||
|
# Install packages though apt
|
||||||
|
handleStatus(subprocess.call(["apt", "install", "-y", "libpam-python", "fswebcam", "libopencv-dev", "python-opencv"]))
|
||||||
|
|
||||||
|
log("Starting camera check")
|
||||||
|
|
||||||
|
# Get all devices
|
||||||
|
devices = os.listdir("/dev")
|
||||||
|
# The picked video device id
|
||||||
|
picked = False
|
||||||
|
|
||||||
|
# Loop though all devices
|
||||||
|
for dev in devices:
|
||||||
|
# Only use the video devices
|
||||||
|
if (dev[:5] == "video"):
|
||||||
|
time.sleep(.5)
|
||||||
|
|
||||||
|
# The full path to the device is the default name
|
||||||
|
device_name = "/dev/" + dev
|
||||||
|
# Get the udevadm details to try to get a better name
|
||||||
|
udevadm = subprocess.check_output(["udevadm info -r --query=all -n " + device_name], shell=True).decode("utf-8")
|
||||||
|
|
||||||
|
# Loop though udevadm to search for a better name
|
||||||
|
for line in udevadm.split("\n"):
|
||||||
|
# Match it and encase it in quotes
|
||||||
|
re_name = re.search('product.*=(.*)$', line, re.IGNORECASE)
|
||||||
|
if re_name:
|
||||||
|
device_name = '"' + re_name.group(1) + '"'
|
||||||
|
|
||||||
|
# Show what device we're using
|
||||||
|
print("Trying " + device_name)
|
||||||
|
|
||||||
|
# Let fswebcam keep the camera open in the background
|
||||||
|
sub = subprocess.Popen(["fswebcam -S 9999999999 -d /dev/" + dev + " /dev/null 2>/dev/null"], shell=True, preexec_fn=os.setsid)
|
||||||
|
|
||||||
|
# Ask the user if this is the right one
|
||||||
|
print("\033[33mOne of your cameras should now be on.\033[0m")
|
||||||
|
ans = input("Did your IR emitters turn on? [y/N]: ")
|
||||||
|
|
||||||
|
# The user has answered, kill fswebcam
|
||||||
|
os.killpg(os.getpgid(sub.pid), signal.SIGTERM)
|
||||||
|
|
||||||
|
# Set this camera as picked if the answer was yes, go to the next one if no
|
||||||
|
if (ans.lower() == "y"):
|
||||||
|
picked = dev[5:]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print("Inerpeting as a \"NO\"\n")
|
||||||
|
|
||||||
|
# Abort if no camera was picked
|
||||||
|
if (picked == False):
|
||||||
|
print("\033[31mNo suitable IR camera found\033[0m")
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
log("Cloning dlib")
|
||||||
|
|
||||||
|
# Clone the git to /tmp
|
||||||
|
handleStatus(subprocess.call(["git", "clone", "https://github.com/davisking/dlib.git", "/tmp/dlib_clone"]))
|
||||||
|
|
||||||
|
log("Building dlib")
|
||||||
|
|
||||||
|
# Start the build without GPU
|
||||||
|
handleStatus(subprocess.call(["cd /tmp/dlib_clone/; python3 setup.py install --yes USE_AVX_INSTRUCTIONS --no DLIB_USE_CUDA"], shell=True))
|
||||||
|
|
||||||
|
log("Cleaning up dlib")
|
||||||
|
|
||||||
|
# Remove the no longer needed git clone
|
||||||
|
handleStatus(subprocess.call(["rm", "-rf", "/tmp/dlib_clone"]))
|
||||||
|
|
||||||
|
log("Installing face_recognition")
|
||||||
|
|
||||||
|
# Install face_recognition though pip
|
||||||
|
handleStatus(subprocess.call(["pip3", "install", "face_recognition"]))
|
||||||
|
|
||||||
|
log("Cloning howdy")
|
||||||
|
|
||||||
|
# Make sure /lib/security exists
|
||||||
|
if not os.path.exists("/lib/security"):
|
||||||
|
os.makedirs("/lib/security")
|
||||||
|
|
||||||
|
# Clone howdy into it
|
||||||
|
handleStatus(subprocess.call(["git", "clone", "https://github.com/Boltgolt/howdy.git", "/lib/security/howdy"]))
|
||||||
|
|
||||||
|
# Manually change the camera id to the one picked
|
||||||
|
for line in fileinput.input(["/lib/security/howdy/config.ini"], inplace = 1):
|
||||||
|
print(line.replace("device_id = 1", "device_id = " + picked), end="")
|
||||||
|
|
||||||
|
# Secure the howdy folder
|
||||||
|
handleStatus(subprocess.call(["chmod 600 -R /lib/security/howdy/"], shell=True))
|
||||||
|
|
||||||
|
# Make the CLI executable as howdy
|
||||||
|
handleStatus(subprocess.call(["ln -s /lib/security/howdy/cli.py /usr/bin/howdy"], shell=True))
|
||||||
|
handleStatus(subprocess.call(["chmod +x /usr/bin/howdy"], shell=True))
|
||||||
|
|
||||||
|
# Install the command autocomplete, don't error on failure
|
||||||
|
subprocess.call(["sudo cp /lib/security/howdy/autocomplete.sh /etc/bash_completion.d/howdy"], shell=True)
|
||||||
|
|
||||||
|
log("Adding howdy as PAM module")
|
||||||
|
|
||||||
|
# Will be filled with the actual output lines
|
||||||
|
outlines = []
|
||||||
|
# Will be fillled with lines that contain coloring
|
||||||
|
printlines = []
|
||||||
|
# Track if the new lines have been insterted yet
|
||||||
|
inserted = False
|
||||||
|
|
||||||
|
# Open the PAM config file
|
||||||
|
with open("/etc/pam.d/common-auth") as fp:
|
||||||
|
# Read the first line
|
||||||
|
line = fp.readline()
|
||||||
|
|
||||||
|
while line:
|
||||||
|
# Add the line to the output directly, we're not deleting anything
|
||||||
|
outlines.append(line)
|
||||||
|
|
||||||
|
# Print the comments in gray and don't insert into comments
|
||||||
|
if line[:1] == "#":
|
||||||
|
printlines.append("\033[37m" + line + "\033[0m")
|
||||||
|
else:
|
||||||
|
printlines.append(line)
|
||||||
|
|
||||||
|
# If it's not a comment and we haven't inserted yet
|
||||||
|
if not inserted:
|
||||||
|
# Set both the comment and the linking line
|
||||||
|
line_comment = "# Howdy IR face recognition\n"
|
||||||
|
line_link = "auth sufficient pam_python.so /lib/security/howdy/pam.py\n\n"
|
||||||
|
|
||||||
|
# Add them to the output without any markup
|
||||||
|
outlines.append(line_comment)
|
||||||
|
outlines.append(line_link)
|
||||||
|
|
||||||
|
# Make the print orange to make it clear what's being added
|
||||||
|
printlines.append("\033[33m" + line_comment + "\033[0m")
|
||||||
|
printlines.append("\033[33m" + line_link + "\033[0m")
|
||||||
|
|
||||||
|
# Mark as inserted
|
||||||
|
inserted = True
|
||||||
|
|
||||||
|
# Go to the next line
|
||||||
|
line = fp.readline()
|
||||||
|
|
||||||
|
# Print a file Header
|
||||||
|
print("\033[33m" + ">>> START OF /etc/pam.d/common-auth" + "\033[0m")
|
||||||
|
|
||||||
|
# Loop though all printing lines and use the enters from the file
|
||||||
|
for line in printlines:
|
||||||
|
print(line, end="")
|
||||||
|
|
||||||
|
# Print a footer
|
||||||
|
print("\033[33m" + ">>> END OF /etc/pam.d/common-auth" + "\033[0m" + "\n")
|
||||||
|
|
||||||
|
# Ask the user if this change is okay
|
||||||
|
print("Lines will be insterted in /etc/pam.d/common-auth as shown above")
|
||||||
|
ans = input("Apply this change? [y/N]: ")
|
||||||
|
|
||||||
|
# Abort the whole thing if it's not
|
||||||
|
if (ans.lower() != "y"):
|
||||||
|
print("Inerpeting as a \"NO\", aborting")
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
print("Adding lines to PAM\n")
|
||||||
|
|
||||||
|
# Write to PAM
|
||||||
|
common_auth = open("/etc/pam.d/common-auth", "w")
|
||||||
|
common_auth.write("".join(outlines))
|
||||||
|
common_auth.close()
|
||||||
|
|
||||||
|
# From here onwards the installation is complete
|
||||||
|
# We want to gather more information about the types or IR camera's
|
||||||
|
# used though, and the following lines are data collection
|
||||||
|
|
||||||
|
# List all video devices
|
||||||
|
diag_out = "Video devices [IR=" + picked + "]\n"
|
||||||
|
diag_out += "```\n"
|
||||||
|
diag_out += subprocess.check_output(['ls /dev/ | grep video'], shell=True).decode("utf-8")
|
||||||
|
diag_out += "```\n"
|
||||||
|
|
||||||
|
# Get some info from the USB kernel listings
|
||||||
|
diag_out += "Lsusb output\n"
|
||||||
|
diag_out += "```\n"
|
||||||
|
diag_out += subprocess.check_output(['lsusb -vvvv | grep -i "Camera\|iFunction"'], shell=True).decode("utf-8")
|
||||||
|
diag_out += "```\n"
|
||||||
|
|
||||||
|
# Get camera information from video4linux
|
||||||
|
diag_out += "Udevadm\n"
|
||||||
|
diag_out += "```\n"
|
||||||
|
diag_out += subprocess.check_output(['udevadm info -r --query=all -n /dev/video' + picked + ' | grep -i "ID_BUS\|ID_MODEL_ID\|ID_VENDOR_ID\|ID_V4L_PRODUCT\|ID_MODEL"'], shell=True).decode("utf-8")
|
||||||
|
diag_out += "```"
|
||||||
|
|
||||||
|
# Print it all as a clickable link to a new github issue
|
||||||
|
print("https://github.com/Boltgolt/howdy-reports/issues/new?title=Post-installation%20camera%20information&body=" + urllib.parse.quote_plus(diag_out) + "\n")
|
||||||
|
|
||||||
|
# Let the user know what to do with the link
|
||||||
|
print("Installation complete.")
|
||||||
|
print("If you want to help the development, please use the link above to post some camera-related information to github")
|
||||||
|
|
||||||
|
# Remove the installer if it was downloaded to /tmp
|
||||||
|
if os.path.exists("/tmp/howdy_install.py"):
|
||||||
|
os.remove("/tmp/howdy_install.py")
|
||||||
6
pam.py
6
pam.py
|
|
@ -1,4 +1,4 @@
|
||||||
# PAM interface in python, launches compair.py
|
# PAM interface in python, launches compare.py
|
||||||
|
|
||||||
# Import required modules
|
# Import required modules
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -15,8 +15,8 @@ config.read(os.path.dirname(os.path.abspath(__file__)) + "/config.ini")
|
||||||
def doAuth(pamh):
|
def doAuth(pamh):
|
||||||
"""Start authentication in a seperate process"""
|
"""Start authentication in a seperate process"""
|
||||||
|
|
||||||
# Run compair as python3 subprocess to circumvent python version and import issues
|
# Run compare as python3 subprocess to circumvent python version and import issues
|
||||||
status = subprocess.call(["python3", os.path.dirname(os.path.abspath(__file__)) + "/compair.py", pamh.get_user()])
|
status = subprocess.call(["python3", os.path.dirname(os.path.abspath(__file__)) + "/compare.py", pamh.get_user()])
|
||||||
|
|
||||||
# Status 10 means we couldn't find any face models
|
# Status 10 means we couldn't find any face models
|
||||||
if status == 10:
|
if status == 10:
|
||||||
|
|
|
||||||
36
uninstall.py
Normal file
36
uninstall.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Completely remove howdy from the system
|
||||||
|
|
||||||
|
# Import required modules
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Check if we're running as root
|
||||||
|
user = os.getenv("SUDO_USER")
|
||||||
|
if user is None:
|
||||||
|
print("Please run the uninstaller as a sudo user")
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
# Double check with the user for the last time
|
||||||
|
print("This will remove Howdy and all models generated with it")
|
||||||
|
ans = input("Do you want to continue? [y/N]: ")
|
||||||
|
|
||||||
|
# Abort if they don't say yes
|
||||||
|
if (ans.lower() != "y"):
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
# Remove files and symlinks
|
||||||
|
subprocess.call(["rm -rf /lib/security/howdy/"], shell=True)
|
||||||
|
subprocess.call(["rm /usr/bin/howdy"], shell=True)
|
||||||
|
subprocess.call(["rm /etc/bash_completion.d/howdy"], shell=True)
|
||||||
|
|
||||||
|
# Remove face_recognition and dlib
|
||||||
|
subprocess.call(["pip3 uninstall face_recognition dlib -y"], shell=True)
|
||||||
|
|
||||||
|
# Print a tearbending message
|
||||||
|
print("""
|
||||||
|
Howdy has been uninstalled :'(
|
||||||
|
|
||||||
|
There are still lines in /etc/pam.d/common-auth that can't be removed automatically
|
||||||
|
Run "nano /etc/pam.d/common-auth" to remove them by hand\
|
||||||
|
""")
|
||||||
25
utils.py
25
utils.py
|
|
@ -1,25 +0,0 @@
|
||||||
# Useful support functions
|
|
||||||
|
|
||||||
def print_menu(encodings):
|
|
||||||
"""Show a menu asking the user what he wants to do"""
|
|
||||||
if len(encodings) == 3:
|
|
||||||
print("There is 1 existing face model for this user")
|
|
||||||
else:
|
|
||||||
print("There are " + str(int(len(encodings) / 3)) + " existing face models for this user")
|
|
||||||
print("What do you want to do?\n")
|
|
||||||
|
|
||||||
print("1: Add additional face model")
|
|
||||||
print("2: Overwrite older model(s)")
|
|
||||||
print("0: Exit")
|
|
||||||
|
|
||||||
com = input("Option: ")
|
|
||||||
|
|
||||||
if com == "1":
|
|
||||||
return encodings
|
|
||||||
elif com == "2":
|
|
||||||
return []
|
|
||||||
elif com == "0":
|
|
||||||
sys.exit()
|
|
||||||
else:
|
|
||||||
print("Invalid option '" + com + "'\n")
|
|
||||||
return print_menu(encodings)
|
|
||||||
Loading…
Reference in a new issue