diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..9057286 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,130 @@ +name: Docker + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + push: + branches: [ "main" ] + # Publish semver tags as releases. + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "main" ] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + TAG_ARM64: path-variable/python-slack-fr:${{ github.ref_name }}-arm64 + + TAG_AMD64: path-variable/python-slack-fr:${{ github.ref_name }}-amd64 + + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Install the cosign tool except on PR + # https://github.com/sigstore/cosign-installer + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1 + with: + cosign-release: 'v2.1.1' + + # Set up BuildKit Docker container builder to be able to build + # multi-platform images and export cache + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta-arm64 + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_ARM64 }} + + - name: Extract Docker metadata + id: meta-amd64 + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_AMD64 }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push-arm64 + uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 + with: + context: . + file: dockerfile-arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ env.REGISTRY }}/${{ env.TAG_ARM64 }} + labels: ${{ steps.meta-arm64.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/arm64 + + - name: Build and push Docker image + id: build-and-push-amd64 + uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 + with: + context: . + file: dockerfile-amd64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ env.REGISTRY }}/${{ env.TAG_AMD64 }} + labels: ${{ steps.meta-amd64.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64 + + # Sign the resulting Docker image digest except on PRs. + # This will only write to the public Rekor transparency log when the Docker + # repository is public to avoid leaking data. If you would like to publish + # transparency data even for private images, pass --force to cosign below. + # https://github.com/sigstore/cosign + - name: Sign the published Docker image + if: ${{ github.event_name != 'pull_request' }} + env: + # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push-arm64.outputs.digest }} + # This step uses the identity token to provision an ephemeral certificate + # against the sigstore community Fulcio instance. + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} + - name: Sign the published Docker image + if: ${{ github.event_name != 'pull_request' }} + env: + # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push-amd64.outputs.digest }} + # This step uses the identity token to provision an ephemeral certificate + # against the sigstore community Fulcio instance. + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9bf6b69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv +.idea +*.iml +__pycache__ +debug.log +venv diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a7e4cc4 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,47 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Remote Attach", + "type": "python", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ], + "justMyCode": true + }, + { + "name": "Python: Flask", + "type": "python", + "request": "launch", + "module": "flask", + "env": { + "FLASK_APP": "main.py", + "FLASK_DEBUG": "1", + "SLACK_API_TOKEN": "", + "MONGO_CONNECTION_STRING": "mongodb://localhost:27017", + "WRITE_SLACK_CHANNEL_ID": "", + "READ_SLACK_CHANNEL_ID": "", + "SLACK_VERIFICATION_TOKEN": "" + }, + "args": [ + "run", + "--no-debugger", + "--no-reload", + "--host=0.0.0.0" + ], + "jinja": true, + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..89d6710 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Ivan Šarić + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aa3296e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.0' +services: + api: + build: + context: . + dockerfile: dockerfile-amd64 + ports: + - "5000:5000" + - "5678:5678" + environment: + - SLACK_API_TOKEN= + - MONGO_CONNECTION_STRING= + - READ_SLACK_CHANNEL_ID= + - WRITE_SLACK_CHANNEL_ID= + - SLACK_VERIFICATION_TOKEN= + depends_on: + - mongo + command: 'python3 -m debugpy --listen 0.0.0.0:5678 -m flask --app main run --host=0.0.0.0' + mongo: + image: mongo + ports: + - "27017:27017" \ No newline at end of file diff --git a/dockerfile-amd64 b/dockerfile-amd64 new file mode 100644 index 0000000..28c751b --- /dev/null +++ b/dockerfile-amd64 @@ -0,0 +1,13 @@ +FROM ghcr.io/isaric/docker-dlib:python-opencv-4.8.0-dlib-19.24.2-amd64 + +WORKDIR /python-docker + +COPY requirements.txt requirements.txt +RUN pip3 install -r requirements.txt +RUN pip install debugpy + +COPY . . + +EXPOSE 5000 + +CMD [ "python3", "-m" , "flask", "--app", "main", "run", "--host=0.0.0.0"] diff --git a/dockerfile-arm64 b/dockerfile-arm64 new file mode 100644 index 0000000..3344193 --- /dev/null +++ b/dockerfile-arm64 @@ -0,0 +1,13 @@ +FROM ghcr.io/isaric/docker-dlib:python-opencv-4.8.0-dlib-19.24.2-arm64 + +WORKDIR /python-docker + +COPY requirements.txt requirements.txt +RUN pip3 install -r requirements.txt +RUN pip install debugpy + +COPY . . + +EXPOSE 5000 + +CMD [ "python3", "-m" , "flask", "--app", "main", "run", "--host=0.0.0.0"] diff --git a/face_recog/detector.py b/face_recog/detector.py new file mode 100644 index 0000000..c2e7667 --- /dev/null +++ b/face_recog/detector.py @@ -0,0 +1,96 @@ +import cv2 +import face_recognition +from datetime import datetime +import logging +import os +import urllib.request as request + +class Detector: + + def __init__(self, channel_id, slack_client, repository): + self.channel_id = channel_id + self.slack_client = slack_client + self.repository = repository + + def detect(self, image_url): + image = self._get_image(image_url) + detected_embeddings, faces = self._get_embeddings(image) + if len(detected_embeddings) == 0: + logging.info("No faces detected") + return + + all_embeddings = self.repository.get_all_embeddings() + recognized, unknown = self._compare_with_existing(all_embeddings, detected_embeddings, faces) + + if len(recognized) > 0: + logging.info("Sending message with recognized faces") + self._send_recognized_message(image,recognized) + + if len(unknown) > 0: + logging.info("Sending message with unknown faces") + self._send_unknown_message(unknown, image) + self._save_embeddings(unknown) + + def _get_image(self, image_url): + logging.info("Downloading image from %s", image_url) + req = request.Request(image_url) + req.add_header('Authorization', 'Bearer ' + os.environ['SLACK_API_TOKEN']) + return face_recognition.load_image_file(request.urlopen(req)) + + def _get_embeddings(self, image): + faces = face_recognition.face_locations(image) + return face_recognition.face_encodings(image, faces), faces + + def _compare_with_existing(self, existing, detected, faces): + if len(existing) == 0: + logging.info("No existing embeddings found") + return [], tuple(zip(detected, faces)) + + recognized = [] + unknown = [] + + for d in detected: + box = faces[detected.index(d)] + e, m = self._is_match(existing, d) + if m: + name = e["name"] + logging.info(f"Found match - {name}") + recognized.append((name, box)) + else: + unknown.append((d,box)) + + return recognized, unknown + + + def _is_match(self, existing, detected): + match = face_recognition.compare_faces([e["embedding"] for e in existing], detected) + for i,m in enumerate(match): + if m: + return existing[i], True + return None, False + + def _save_embeddings(self, embeddings): + for embedding, _ in embeddings: + self.repository.save_embedding({"name": "unknown", "embedding": embedding.tolist(), "created_date": datetime.now()}) + + def _send_unknown_message(self, unknown, image): + message = f"{len(unknown)} unknown faces detected" + colorim = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) + for _, (top, right, bottom, left) in unknown: + cv2.rectangle(colorim, (left, top), (right, bottom), + (0, 255, 225), 2) + _, bts = cv2.imencode('.png', colorim) + self.slack_client.send_image(self.channel_id, message, bts.tostring()) + + def _send_recognized_message(self, image, recognized): + for (name,(top, right, bottom, left)) in recognized: + colorim = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) + cv2.rectangle(colorim, (left, top), (right, bottom),(0, 255, 225), 2) + y = top - 15 if top - 15 > 15 else top + 15 + cv2.putText(colorim, name, (left, y), cv2.FONT_HERSHEY_SIMPLEX, + .8, (0, 255, 255), 2) + _, bts = cv2.imencode('.png', colorim) + self.slack_client.send_image_no_msg(channel_id=self.channel_id, image=bts.tostring()) + + + diff --git a/main.py b/main.py new file mode 100644 index 0000000..13d89eb --- /dev/null +++ b/main.py @@ -0,0 +1,59 @@ +from flask import Flask, request +from face_recog.detector import Detector +from mongo_client.repository import Repository +import os +import logging +from threading import Thread + +from slack_client.slack import SlackClient + +class DetectorFacade: + + def __init__(self, detector, slack_verification_token, read_channels): + self.detector = detector + self.slack_verification_token = slack_verification_token + self.read_channels = read_channels + + def process(self, content): + if content["token"] != self.slack_verification_token: + app.logger.info("Invalid token") + return + if "event" in content and "files" in content["event"]: + app.logger.info("Received event contains files") + if "image" not in content["event"]["files"][0]["mimetype"]: + app.logger.info("Received file is not an image") + return + if content["event"]["channel"] in self.read_channels: + detector.detect(content["event"]["files"][0]["url_private_download"]) + else: + app.logger.info(f'Channel ID {content["event"]["channel"]} is not on read list. Skipping event') + +app = Flask(__name__) + + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.FileHandler("debug.log"), + logging.StreamHandler() + ] +) + +slack_client = SlackClient(os.environ['SLACK_API_TOKEN']) +repository = Repository(os.environ['MONGO_CONNECTION_STRING']) +detector = Detector(os.environ['WRITE_SLACK_CHANNEL_ID'], slack_client, repository) +detector_facade = DetectorFacade(detector, os.environ['SLACK_VERIFICATION_TOKEN'], os.environ['READ_SLACK_CHANNEL_ID']) + +@app.route('/api/events', methods=['POST']) +def add_message(): + content = request.get_json(silent=True) + app.logger.info("received incoming event %s", content) + if content["type"] == "url_verification": + # TODO: get token from initial challenge instead of using env variable + return content["challenge"] + Thread(target=detector_facade.process, args=(content,)).start() + return "OK" + + + \ No newline at end of file diff --git a/mongo_client/repository.py b/mongo_client/repository.py new file mode 100644 index 0000000..2e5b61c --- /dev/null +++ b/mongo_client/repository.py @@ -0,0 +1,15 @@ +import pymongo + +class Repository: + + def __init__(self, connection_string): + self.client = pymongo.MongoClient(connection_string) + self.db = self.client['facial_embeddings'] + + def save_embedding(self, embedding): + embeddings = self.db['embeddings'] + embeddings.insert_one(embedding) + + def get_all_embeddings(self): + embeddings = self.db['embeddings'] + return [e for e in embeddings.find({})] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8c0688a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,30 @@ +aiohttp==3.9.1 +aiosignal==1.3.1 +asgiref==3.7.2 +async-timeout==4.0.3 +attrs==23.1.0 +blinker==1.7.0 +certifi==2023.11.17 +charset-normalizer==3.3.2 +click==8.1.7 +dlib==19.24.2 +dnspython==2.4.2 +face-recognition==1.3.0 +face-recognition-models==0.3.0 +Flask==3.0.0 +frozenlist==1.4.0 +idna==3.6 +itsdangerous==2.1.2 +Jinja2==3.1.2 +MarkupSafe==2.1.3 +multidict==6.0.4 +numpy==1.26.2 +opencv-python==4.8.1.78 +Pillow==10.1.0 +pymongo==4.6.1 +requests==2.31.0 +slackclient==2.9.4 +typing_extensions==4.9.0 +urllib3==2.1.0 +Werkzeug==3.0.1 +yarl==1.9.4 diff --git a/slack_client/slack.py b/slack_client/slack.py new file mode 100644 index 0000000..5da8b30 --- /dev/null +++ b/slack_client/slack.py @@ -0,0 +1,17 @@ +from slack import WebClient + +class SlackClient: + + def __init__(self, token): + self.client = WebClient(token=token) + + def send_image(self, channel_id, message, image): + self.client.files_upload( + channels=channel_id, + content=image,filename="image.png", + initial_comment=message) + + def send_image_no_msg(self, channel_id, image): + self.client.files_upload( + channels=channel_id, + content=image, filename="image.png") \ No newline at end of file