Compare commits

...

4 commits

Author SHA1 Message Date
Prunebutt
d1552b3eb5 add readme, pyproject and resources 2026-03-26 13:00:27 +01:00
Prunebutt
4df206981f make cli a mvp 2025-10-19 23:33:47 +02:00
Prunebutt
11012b7db5 fix the develop shell 2025-10-19 22:41:57 +02:00
Prunebutt
afe6dde499 fix flake.lock 2025-10-19 22:32:22 +02:00
18 changed files with 1847 additions and 128 deletions

12
.envrc Normal file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env bash
export DIRENV_WARN_TIMEOUT=20s
eval "$(devenv direnvrc)"
# `use devenv` supports the same options as the `devenv shell` command.
#
# To silence all output, use `--quiet`.
#
# Example usage: use devenv --quiet --impure --option services.postgres.enable:bool true
use devenv

View file

@ -1,2 +1,35 @@
# protestswap # Protestswap
## Motivation
Fotos auf social media zu verbreiten ist für politische Gruppen ein zweischneidiges Schwert.
Einerseits möchte mensch die Identitäten der aktiven schützen, weswegen es gängig ist, die Gesichter zu verpixeln.
Andererseits kommt das Verpixeln der Gesichter bei der bürgerlich geprägten Öffentlichkeit nicht sehr gut an.
Es macht den Eindruck, als wären nur Verbrechys und Tankies an der Organisation beteiligt.
## Lösungsansatz
Snapchat kann seit der Mitte der 2010er Jahre live Gesichter lokal auf dem Handy swappen.
Mit den heute verfügbaren CV-tools und Machine-learning resourcen, sollte es möglich sein, Gesichter auf Fotos authentisch genug auszutauschen, um gleichzeitig die Identitäten von Aktivistys zu schützen und die Optik des zu teilenden Bildes nicht kaputt zu machen - und zwar ohne auf die turbokapitalistischen Unionbuster aus Silicon Valley, Redmond und Südafrika zurückzugreifen (OpenAI, Grok, Microsoft, usw.).
Im Idealfall werden die Gesichter von echten Personen mit KI-generierten menschlichen Gesichtern ausgetauscht, um keine Persönlichkeitsrechte von celebrities zu verletzen und sich damit rechtlich angreifbar zu machen. Zum Beispiel aus einem [Random-Face-Generator](https://this-person-does-not-exist.com/de).
## Requirements
- Faceswapping von allen Gesichtern auf einem Foto
- Lokal ausführbar auf einem PC
### Nice-To-Have
- *Prio A*: Einfache GUI vom Handy aus
- *Prio B*: Angeben von Bereichen, wo Gesichter nicht geswappt werden (z.B. um nicht Emma Goldman auf Plakaten zu faceswappen)
- *Prio C*: Lokal ausführbar auf einem Handy
## Technik
Disclaimer: Ich bin weder sehr bewandert in Machine-learning, noch darin eine fertige App zu entwickeln. Ich mache hauptsächlich devops und Linux-kram.
### Backend
Als Backend wird [`insightface`](https://github.com/deepinsight/insightface) genutzt. Ich habe online ein älteres Repo gefunden, das die Funktionalität hat und das umgeschrieben.
Das Modell ist von Huggingface zu haben

0
devenv.lock Normal file
View file

23
flake.lock generated
View file

@ -34,10 +34,31 @@
"type": "github" "type": "github"
} }
}, },
"pyproject-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1760402624,
"narHash": "sha256-jF6UKLs2uGc2rtved8Vrt58oTWjTQoAssuYs/0578Z4=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "84c4ea102127c77058ea1ed7be7300261fafc7d2",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs",
"pyproject-nix": "pyproject-nix"
} }
}, },
"systems": { "systems": {

View file

@ -49,8 +49,8 @@
ls -l $src ls -l $src
echo $src echo $src
mkdir -p "$out/var/lib/insightface/buffalo_l/" mkdir -p "$out/var/lib/insightface/models/"
unar -D -o $out/var/lib/insightface/buffalo_l/ $src unar -D -o $out/var/lib/insightface/models/buffalo_l/ $src
''; '';
nativeBuildInputs = [ pkgs.unar ]; nativeBuildInputs = [ pkgs.unar ];
@ -74,9 +74,12 @@
arg = project.renderers.mkPythonEditablePackage { inherit python; }; arg = project.renderers.mkPythonEditablePackage { inherit python; };
pythonEnv = python.pkgs.mkPythonEditablePackage ( arg ); pythonEnv = python.pkgs.mkPythonEditablePackage ( arg );
in pkgs.mkShell { in pkgs.mkShell {
packages = [ pkgs.python3 pkgs.uv packages = with pkgs; [
# buffaloModel python3
protestswap pkgs.gcc14 ]; uv
gcc14
python3Packages.fastapi
] ++ [protestswap];
shellHook = /*shell*/ shellHook = /*shell*/
'' ''
ls -l ${buffaloModel} ls -l ${buffaloModel}
@ -84,10 +87,10 @@
INSIGHTFACE_ROOT_DIR=$(mktemp -d /tmp/insightface.XXXXXXXX) INSIGHTFACE_ROOT_DIR=$(mktemp -d /tmp/insightface.XXXXXXXX)
export INSIGHTFACE_ROOT_DIR export INSIGHTFACE_ROOT_DIR
mkdir -p "$INSIGHTFACE_ROOT_DIR" mkdir -p "$INSIGHTFACE_ROOT_DIR/models/"
ln -s ${inswapperModel}/var/lib/insightface/models "$INSIGHTFACE_ROOT_DIR/models" ln -s ${inswapperModel}/var/lib/insightface/models/inswapper_128.onnx "$INSIGHTFACE_ROOT_DIR/models/"
ln -s ${buffaloModel}/var/lib/insightface/buffalo_l "$INSIGHTFACE_ROOT_DIR/buffalo_l" ln -s ${buffaloModel}/var/lib/insightface/models/buffalo_l "$INSIGHTFACE_ROOT_DIR/models/buffalo_l"
# export PROTESTSWAP_ROOT="/tmp/protestswap_models" # export PROTESTSWAP_ROOT="/tmp/protestswap_models"
# mkdir -p "$PROTESTSWAP_ROOT/buffalo_l" # mkdir -p "$PROTESTSWAP_ROOT/buffalo_l"
@ -102,82 +105,3 @@
packages.${system}.default = protestswap; packages.${system}.default = protestswap;
}; };
} }
# flake-utils.lib.eachDefaultSystem (system: let
# pyproject = builtins.fromTOML (builtins.readFile ./pyproject.toml);
# project = pyproject.project;
#
# package = pkgs.python3Packages.buildPythonPackage {
# pname = project.name;
# inherit (project) version;
# format = "pyproject";
#
# src = ./.;
#
# build-system = with pkgs.python3Packages; [
# uv-build
# ];
#
# # # test dependencies
# nativeCheckInputs = with pkgs; [
# # python3Packages.mypy
# # python3Packages.pytest
# taplo
# ];
#
# # checkPhase = '''';
#
# propagatedBuildInputs = with pkgs; [
# python3Packages.click
# python3Packages.insightface
#
# ] ++ builtins.map (dep: pkgs.python3Packages.${dep}) project.dependencies;
# };
#
# editablePackage = pkgs.python3.pkgs.mkPythonEditablePackage {
# pname = project.name;
# inherit (project) scripts version;
# root = "$PWD";
# };
# in {
# devShells = {
# default = pkgs.mkShell {
# inputsFrom = [
# package
# ];
#
# buildInputs = [
# # our package
# editablePackage
#
# #################
# # VARIOUS TOOLS #
# #################
#
# pkgs.python3Packages.build
# pkgs.python3Packages.ipython
# pkgs.python3Packages.insightface
# inswapperModel
#
# ####################
# # EDITOR/LSP TOOLS #
# ####################
#
# # LSP server:
# pkgs.python3Packages.python-lsp-server
#
# # LSP server plugins of interest:
# pkgs.python3Packages.pylsp-mypy
# pkgs.python3Packages.pylsp-rope
# pkgs.python3Packages.python-lsp-ruff
# ];
# };
# };
#
# packages = {
# "inswapper-model" = inswapperModel;
# "${project.name}" = package;
# default = self.packages.${system}.${project.name};
# };
# });

View file

@ -6,13 +6,16 @@ description = "A minimal pyproject.toml"
authors = [ ] authors = [ ]
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"insightface", "insightface",
"onnxruntime", "onnxruntime",
"opencv-python", "matplotlib" "opencv-python",
"matplotlib",
"fastapi[standard]",
] ]
[project.scripts] [project.scripts]
protestswap-cli = "protestswap.cli:main" protestswap-cli = "protestswap.cli:main"
# protestswap-server = "protestswap.server:"
[build-system] [build-system]
requires = ["uv_build >= 0.8.17, <0.9.0"] requires = ["uv_build >= 0.8.17, <0.9.0"]

BIN
resources/five_faces.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

BIN
resources/greta.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
resources/henry_cavill.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

BIN
resources/many_faces_target Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

BIN
resources/roger_moore.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 KiB

1
result Symbolic link
View file

@ -0,0 +1 @@
/nix/store/zadcgfd4y5c41acjm1cabk5kdaix6xa7-python3.13-protestswap-0.0.1

BIN
result.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

View file

@ -1,6 +1,10 @@
import os import os
import logging import logging
import argparse
import cv2
from protestswap.protestswap import ProtestFaceSwapper from protestswap.protestswap import ProtestFaceSwapper
logger = logging.getLogger("protestswap") logger = logging.getLogger("protestswap")
@ -10,19 +14,47 @@ INSIGHTFACE_VAR = "INSIGHTFACE_ROOT_DIR"
def main(): def main():
parser = argparse.ArgumentParser(
prog="protestswap", description="Swaps faces of people on protests"
)
parser.add_argument("target", help="The image to swap the faces on")
parser.add_argument(
"-t", "--template", action="append", help="The template faces to paste on"
)
args = parser.parse_args()
print(args.target)
if not args.template:
print("You need at least one template (with '-t')!")
exit(1)
insightface_dir = None insightface_dir = None
if os.path.exists(DEFAULT_INSIGHTFACE_DIR): if os.environ.get(INSIGHTFACE_VAR):
insightface_dir = DEFAULT_INSIGHTFACE_DIR
elif os.environ.get(INSIGHTFACE_VAR):
insightface_dir = os.environ.get(INSIGHTFACE_VAR) insightface_dir = os.environ.get(INSIGHTFACE_VAR)
elif os.path.exists(DEFAULT_INSIGHTFACE_DIR):
insightface_dir = DEFAULT_INSIGHTFACE_DIR
else: else:
logger.warning( logger.warning(
f"No directory at '{DEFAULT_INSIGHTFACE_DIR}' and '{INSIGHTFACE_VAR}' not set yet." f"No directory at '{DEFAULT_INSIGHTFACE_DIR}' and '{INSIGHTFACE_VAR}' not set yet."
) )
insightface_dir = f"{os.environ.get('HOME')}/.cache/insightface" insightface_dir = f"{os.environ.get('HOME')}/.cache/insightface"
# swapper = ProtestFaceSwapper(insightface_dir) swapper = ProtestFaceSwapper(insightface_dir)
# swapper.prepare_model() swapper.prepare_model()
swapper.set_target_path(args.target)
for template in args.template:
print(f"Adding template {template}...")
swapper.add_template_path(template)
print("Detecting faces")
swapper.detect_target_faces()
swapped = swapper.swap()
cv2.imwrite("result.png", swapped)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,10 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import logging
import os import os
import logging
import cv2 import cv2
import random # TODO: seed! import random # TODO: seed!
import insightface import insightface
from insightface.app import FaceAnalysis from insightface.app import FaceAnalysis
@ -14,51 +14,65 @@ from insightface.app.common import Face
logger = logging.getLogger("protestswap") logger = logging.getLogger("protestswap")
SWAPPING_MODEL = 'inswapper_128.onnx' SWAPPING_MODEL = "inswapper_128.onnx"
class ProtestFaceSwapper: class ProtestFaceSwapper:
def __init__(self, root_dir): def __init__(self, root_dir):
self._app : FaceAnalysis = FaceAnalysis(name='buffalo_l', root=insightface_dir) self._root_dir = root_dir
self._model : INSwapper self._app: FaceAnalysis = FaceAnalysis(name="buffalo_l", root=root_dir)
self._source : cv2.typing.MatLike|None = None self._model: INSwapper
self._source_faces : list[Face] = [] self._target: cv2.typing.MatLike | None = None
self._template_faces : list[Face] = [] self._target_faces: list[Face] = []
self._template_faces: list[Face] = []
self._app.prepare(ctx_id=0, det_size=(640, 640)) self._app.prepare(ctx_id=0, det_size=(640, 640))
def prepare_model(self) -> None: def prepare_model(self) -> None:
self._model = insightface.model_zoo.get_model(SWAPPING_MODEL, download=True, download_zip=True) # Revealed type: "INSwapper" self._model = insightface.model_zoo.get_model(
os.path.join(self._root_dir, "models", SWAPPING_MODEL),
root=self._root_dir,
download=False,
download_zip=False,
) # Revealed type: "INSwapper"
print(f"Model: {self._model}")
def set_source(self, path: str) -> None: def set_target_path(self, path: str) -> None:
self._source = cv2.imread(path) self.set_target(cv2.imread(path))
def detect_source_faces(self): def set_target(self, image: cv2.UMat) -> None:
self._source_faces = self._app.get(self._source) self._target = image
def add_template_faces(self, template_path: str): def detect_target_faces(self):
self._template_faces += [self._app.get(cv2.imread(template_path))] self._target_faces = self._app.get(self._target)
print(f"Found {len(self._target_faces)} faces!")
def add_template_path(self, template_path: str):
self.add_template(cv2.imread(template_path))
def add_template(self, template: cv2.UMat):
self._template_faces += self._app.get(template)
def swap(self) -> cv2.typing.MatLike: def swap(self) -> cv2.typing.MatLike:
work_copy = self._source_faces.copy() work_copy = self._target.copy()
for i, face in enumerate(self._source_faces): for i, face in enumerate(self._target_faces):
print(f"Replacing face {i+1}/{len(self._source_faces)}...", end="", flush=True); print(
f"Replacing face {i + 1}/{len(self._target_faces)}...",
# end="",
# flush=True,
)
imposed_face = random.choice(self._template_faces) imposed_face = random.choice(self._template_faces)
work_copy = self._model.get(work_copy, face, imposed_face, paste_back=True) print(
f"work_copy: {type(work_copy)}, face: {type(face)}, imposed: {
type(imposed_face)
}"
)
work_copy = self._model.get(
work_copy, face, imposed_face, paste_back=True)
print(" done!") print(" done!")
return work_copy return work_copy
@staticmethod @staticmethod
def readFile(path: str) -> cv2.typing.MatLike|None: def readFile(path: str) -> cv2.typing.MatLike | None:
return cv2.imread(path) return cv2.imread(path)
def main():
swapper = ProtestFaceSwapper(insightface_dir)
swapper.prepare_model()
if __name__ == '__main__':
main()

32
src/protestswap/server.py Normal file
View file

@ -0,0 +1,32 @@
from typing import Annotated
from fastapi import FastAPI, File, Form, UploadFile
from pydantic import BaseModel
class Item(BaseModel):
target: Annotated[UploadFile, File()]
template: list[UploadFile]
description: str | None = None
app = FastAPI()
#
# @app.post("/items/")
# async def create_item(item: Item):
# return item
@app.post("/files/")
async def upload_file(
# item: Item,
file: Annotated[bytes, File()],
fileb: list[Annotated[UploadFile, File()]],
token: Annotated[str, Form()],
):
return {
"file_size": len(file),
"token": token,
"fileb_content_type": fileb[0].content_type,
}

1647
uv.lock generated Normal file

File diff suppressed because it is too large Load diff