Depuis que je fais francepunkscene.net, je dois gérer la modération du contenu. En effet, il s'agit d'un vrai petit réseau social sur lequel les utilisateurs peuvent envoyer leurs images et leurs textes, et, en tant qu'hébergeur, je suis responsable des contenus qui sont publiés.
Cela peut être consommateur de temps. Ainsi, tout ce qui permet d'automatiser les tâches est bienvenu.
Nudenet est un package python qui permet de détecter la présence de nudité sur des images. Il s'appuie sur un modèle ONNX pré-entraîné pour effectuer la détection, et n'envoi donc aucun contenu nulle part : tout est détecté en local sur le serveur. On est sur quelque chose de léger, certainement pas parfait, mais ça sera suffisant pour faire un premier pas.
Je commence donc par faire une exploration du package. La documentation en ligne est assez pauvre malheureusement. Il y a bien un exemple en ligne, dans le navigateur. Mais on va devoir comprendre le fonctionnement avec l'exemple du readme, et en lisant le code source. Spoiler alert : tout ça est très simple.
Le fonctionnement est on ne peut plus basique : on passe un chemin d'image, et l'application retourne un tableau contenant tout ce qu'il a détecté. Il recherche pas mal de choses différentes, dont certaines ne nous intéressent pas forcément :
all_labels = [
"FEMALE_GENITALIA_COVERED", # Partie génitale féminine couverte
"FACE_FEMALE", # Visage féminin
"BUTTOCKS_EXPOSED", # Fesses exposées
"FEMALE_BREAST_EXPOSED", # Seins féminins exposés
"FEMALE_GENITALIA_EXPOSED", # Partie génitale féminine exposée
"MALE_BREAST_EXPOSED", # Seins masculins exposés
"ANUS_EXPOSED", # Anus exposé
"FEET_EXPOSED", # Pieds exposés
"BELLY_COVERED", # Ventre couvert
"FEET_COVERED", # Pieds couverts
"ARMPITS_COVERED", # Aisselles couvertes
"ARMPITS_EXPOSED", # Aisselles exposées
"FACE_MALE", # Visage masculin
"BELLY_EXPOSED", # Ventre exposé
"MALE_GENITALIA_EXPOSED", # Partie génitale masculine exposée
"ANUS_COVERED", # Anus couvert
"FEMALE_BREAST_COVERED", # Seins féminins couverts
"BUTTOCKS_COVERED", # Fesses couvertes
]
Le retour est une liste de boite, avec les coordonnées, la classe de ce qui a été détecté, et un score de confiance.
detection_example = [
{'class': 'BELLY_EXPOSED',
'score': 0.799403190612793,
'box': [64, 182, 49, 51]},
{'class': 'FACE_FEMALE',
'score': 0.7881264686584473,
'box': [82, 66, 36, 43]},
]
Il est aussi possible d'utiliser une fonction de censure, qui remplace tout ce qui a été détecté par un carré noir. Ça ne m'intéresse pas, mais ça va permettre de rapidement comprendre ce qui est détecté sur une image.
Je décide donc de passer à un test en conditions réelles.
J'installe donc nudenet
dans mon virtualenv : pip install nudenet
.
Je prépare une première image, la célèbre pochette de Nevermind du groupe Nirvana :
J'ouvre une session ipython
pour voir comment nudenet
se comporte avec cette pochette :
from nudenet import NudeDetector
nevermind = 'src/tests/mocks/covers/nevermind.jpg'
nude_detector.detect(nevermind)
> [{'class': 'FACE_MALE',
'score': 0.47174084186553955,
'box': [204, 121, 87, 78]}]
nude_detector.censor(nevermind)
'src/tests/mocks/covers/nevermind_censored.jpg'
OK, comme on le voit, nudenet
pense avoir détecté un visage masculin avec un niveau de certitude de 47%. J'ai créé une image censurée pour bien voir le résultat de la détection :
Comme on peut le voir, nudenet
n'a pas détecté tout ce qu'il aurait pu détecter 😁 Mais ce n'est pas une mauvaise chose, puisque si Kurt revenait d'entre les morts, prenait un petit appartement en banlieue, et s'inscrivait sur mon site, j'aimerais bien qu'il puisse ajouter son album sans être embêté. Il faudra vérifier que le package fait bien son boulot sur d'autres images.
Le coup de détecter et de censurer les visages est vraiment perturbant, je trouve, mais je ne m'y attarde pas puisque les visages feront partie des classes que j'ignorerais au moment de ma détection à moi.
Au passage, en faisant mon test, je remarque que le package prend plusieurs secondes à charger. J'anticipe à ce moment-là que la détection se fera à travers une tâche Celery, afin de ne pas ralentir l'utilisateur. Mais on y reviendra.
Je continue mon exploration avec une autre pochette d'un disque légendaire : Surfer Rosa des Pixies.
pixies = 'src/tests/mocks/covers/surferrosa.jpg'
nude_detector.detect(pixies)
> []
OK, alors on voit que sur cet exemple, il ne détecte rien du tout. Encore une fois, dans le cadre de pochettes de rock légendaire, ce n'est pas un problème, mais il va falloir quand même qu'il me rassure. En tout cas, la leçon, c'est que bien entendu, l'utilisation de ce package ne fera que faciliter le travail, mais ne suffira pas à elle seule.
Dernier test, avec la pochette de l'album Psycho Tropical Berlin du groupe La Femme :
la_femme = 'src/tests/mocks/covers/Psycho-Tropical-Berlin.jpg'
nude_detector.detect(la_femme)
> [{'class': 'FACE_FEMALE',
'score': 0.8014442920684814,
'box': [182, 85, 83, 84]},
{'class': 'FEMALE_BREAST_EXPOSED',
'score': 0.7772088050842285,
'box': [179, 241, 81, 77]},
{'class': 'FEMALE_BREAST_EXPOSED',
'score': 0.7072852849960327,
'box': [123, 236, 61, 70]},
{'class': 'BELLY_EXPOSED',
'score': 0.5087726712226868,
'box': [146, 324, 93, 69]},
{'class': 'ARMPITS_EXPOSED',
'score': 0.4397861361503601,
'box': [251, 232, 40, 39]}]
OK, donc là, il détecte beaucoup plus de choses. Voici l'image censurée pour illustrer tout ça.
Je me suis ainsi assuré que le package fonctionnait. Évidemment, j'ai fait d'autres tests, en particulier pour comprendre les scores de certitude et pouvoir définir les bons niveaux de ratio à mettre en place dans l'implémentation dans le code de mon site.
Bon, maintenant qu'on a fait joujou avec la librairie, il est temps d'en faire quelque chose dans le projet.
Pour donner un peu de contexte, le projet est un projet Django tout ce qu'il y a de plus classique, avec des modèles qui décrivent des contenus, qui peuvent avoir, pour certains, une image. Lorsqu'un contenu peut être associé à plusieurs images, alors on passe par un modèle intermédiaire qui représente une et une seule image (et ses déclinaisons en taille).
Si vous n'êtes pas familier avec Django, prenez le temps de lire un peu sur le sujet avant, sinon à partir d'ici ça pourrait devenir assez nébuleux. D'autant plus que pour simplifier au maximum cet article, je ne donne pas de code en bout en bout. Simplement des extraits pour illustrer.
OK, donc pour démarrer, je pars du principe que chaque vérification d'image va pouvoir être représenté par un objet modélisé dans Django. Je vais donc créer un modèle ImageVerification
, qui aura un état :
Un objet venant d'être créé est d'abord à l'état PENDING
. Lorsque la tâche de vérification est lancée, l'état est à PROCESSING
. En fonction du retour de la librairie nudenet
, la vérification passe soit à l'état VERIFIED
si rien n'a été détecté au-delà des seuils définis, soit à l'état REJECTED
, soit à l'état SUSPICIOUS
si on a un doute et qu'on demande une intervention humaine.
Vous l'aurez compris, on ne va pas refuser une image dès que nudenet
détecte des choses sur l'image. On va prendre en compte, d'une part, le type de contenu détecté, et d'autre part le degré de précision. Si on peut facilement décider qu'une image affichant des parties génitales sera tout de suite rejetée, il y a toute une série de choses pour lesquels on va juste vouloir créer des alertes pour les administrateurs.
Commençons donc par modéliser la classe ImageVerification. Comme je l'ai dit plus haut, l'idée est qu'un contenu enregistré en base de donnée peut avoir une seule image associée au maximum. On va donc pouvoir utiliser les clés étrangères génériques sur notre modèle, pour qu'il puisse être lié à n'importe quel autre modèle. On renseignera également le nom du champ dans lequel l'image principale est enregistrée. Charge au modèle d'origine de créer une entrée dans ImageVerification
, ce qu'on pourra généraliser à l'aide d'une mixin. Je ne détaillerai pas la mécanique dans cet article.
class ImageVerification(models.Model):
class States(models.TextChoices):
PENDING = 'PENDING', _('Pending')
PROCESSING = 'PROCESSING', _('Processing')
VERIFIED = 'VERIFIED', _('Verified')
SUSPICIOUS = 'SUSPICIOUS', _('Suspicious')
REJECTED = 'REJECTED', _('Rejected')
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
# object_id could be a UUID, so we can't use PositiveIntegerField
object_id = models.CharField(max_length=255)
content_object = GenericForeignKey('content_type', 'object_id')
field_name = models.CharField(max_length=255)
state = models.CharField(
max_length=255,
choices=States.choices,
default=States.PENDING
)
last_verification_result = models.JSONField(
null=True,
blank=True,
)
À noter qu'on prévoit de stocker le retour de nudenet
dans le champ last_verification_result
, ce qui sera utile à des fins de debugging.
OK, maintenant qu'on a la base de notre modèle, on peut implémenter la gestion du retour de nudenet
.
J'ai fait un choix, à ce niveau, de labels qui seront prohibés, et d'autres qui nécessiteront simplement l'intention d'un administrateur s'ils sont rencontrés avec un score de certitude assez élevé. Tous les labels absents sont considérés comme toujours ok (les visages par exemple).
Concernant les labels prohibés, j'ai également fixé un score de certitude minimum pour rejeter automatiquement les images. Si ce score n'est pas atteint, on se contentera de noter l'image comme nécessitant l'attention d'un administrateur.
Le choix de placer tel ou tel label dans telle ou telle catégorie est évidemment totalement discutable. Je ne vais pas rentrer dans un débat sur la morale ici. Gardez juste en tête que j'ai mis ça en place pour me faciliter la vie, et qu'un média rejeté peut toujours être accepté, le cas échéant, après vérification du modérateur.
PROHIBITED_LABELS = [
'BUTTOCKS_EXPOSED',
'FEMALE_BREAST_EXPOSED',
'FEMALE_GENITALIA_EXPOSED',
'ANUS_EXPOSED',
'MALE_GENITALIA_EXPOSED',
]
PROHIBITED_THRESHOLD = 0.2
NEED_ATTENTION_LABELS = [
'FEMALE_GENITALIA_COVERED',
'MALE_BREAST_EXPOSED',
'BELLY_EXPOSED',
'ANUS_COVERED',
'FEMALE_BREAST_COVERED',
'BUTTOCKS_COVERED',
'ARMPITS_EXPOSED',
]
NEED_ATTENTION_THRESHOLD = 0.2
On peut désormais créer une tâche, qu'on appellera via Celery
, et qui sera chargée de lancer la vérification en fonction du retour de nudenet
.
@shared_task(
bind=True,
autoretry_for=(ImageVerification.DoesNotExist,),
retry_kwargs={'max_retries': 7, 'countdown': 5}
)
def verify_image(self, image_verification_id):
image_verification = ImageVerification.objects.get(pk=image_verification_id)
content_object = image_verification.content_object
image = getattr(content_object, image_verification.field_name)
detector = NudeDetector()
image_verification.last_verification_result = detector.detect(image.path)
image_verification.update_state()
image_verification.save()
if image_verification.state in [ImageVerification.States.SUSPICIOUS,
ImageVerification.States.REJECTED]:
image_verification.send_notifications()
Avec la fonction update_state
qui peut se présenter, par exemple, de cette manière :
def update_state(self):
"""Update state from self.last_verification_result"""
final_state = None
for entry in self.last_verification_result:
if entry['class'] in PROHIBITED_LABELS:
if entry['score'] >= PROHIBITED_THRESHOLD:
final_state = self.States.REJECTED
break
if entry['score'] < PROHIBITED_THRESHOLD:
final_state = self.States.SUSPICIOUS
elif (
entry['class'] in NEED_ATTENTION_LABELS
and entry['score'] >= NEED_ATTENTION_THRESHOLD
):
final_state = self.States.SUSPICIOUS
if final_state not in [self.States.REJECTED, self.States.SUSPICIOUS]:
final_state = self.States.VERIFIED
self.state = final_state
Comme je l'ai dit, j'ai laissé la responsabilité aux objets de créer leur demande de vérification lors de la création ou de la modification d'une image.
Voici un extrait simplifié du code qui crée ou récupère l'ImageVerification
et qui lance la tâche asynchrone. Ce code pourra se retrouver dans la méthode save
des modèles affectés.
...
content_type = ContentType.objects.get_for_model(self)
image_verification, _ = ImageVerification.objects.update_or_create(
content_type=content_type,
object_id=self.pk,
field_name=field_name, # on suppose que field_name contient le nom du champ image à vérifier
)
image_verification.state = ImageVerification.States.PROCESSING
image_verification.save()
celery.current_app.send_task(
'management.tasks.verify_image',
args=[image_verification.pk],
)
L'implémentation réelle de tout ça est un peu plus compliquée que ça, il faut penser aux modifications en chaine du champ d'image, aux tâches lancées en concurrence, au fait qu'on ne veuille pas forcément utiliser Celery
lorsqu'on lance les tests unitaires, etc. J'ai essayé ici de simplifier les choses au maximum pour privilégier le partage de l'intention.
Ensuite, à l'utilisation, au moment d'aller afficher ou non une image sur le front, on peut aller vérifier l'état de la vérification via le modèle ImageVerification
.
On pourrait débattre longtemps de ce principe de censure. Mais, en tant qu'hébergeur de contenus, et étant responsable de ce qui est hébergé, l'utilisation d'un tel outil peut grandement me faciliter la vie. De plus, c'est relativement simple à utiliser et plutôt léger. Pour le moment, ça ne m'a pas posé le moindre souci avec mes utilisateurs, puisqu'aucun contenu n'a enfreint les règles d'utilisation, et moi, ça m'offre une certaine tranquillité d'esprit.
2022 - tominardi.fr