notAI-tech / NudeNet

Lightweight nudity detection
https://nudenet.notai.tech/
GNU Affero General Public License v3.0
1.66k stars 335 forks source link

NudeDetector in-browser works significantly better than the Python sample #117

Open Solmyr1982 opened 9 months ago

Solmyr1982 commented 9 months ago

I'm trying to get rid of the nudity in the local files and noticed that some of the files are not properly detected. I tried one of the problematic files in the browser using this link: https://nudenet.notai.tech/ and it works perfectly well. Could you please let me know what could be the reason? p.s. first I upgraded NudeNet, then uninstalled NudeNet and installed it from scratch - nothing helped.

The sample from the browser: image

My local code: image

Jodc2 commented 9 months ago

same problem

bedapudi6788 commented 9 months ago

Huh, the model is the same. It is possible that there is some mistake in the pre processing code.

Let me re-check this. Will update here.

Solmyr1982 commented 9 months ago

Thank you @bedapudi6788. I tried to run the web app locally, using the same model files as used in Python code (best.onnx and nms-yolov8.onnx), and got exactly the same results. Now I'm trying to check the pre-processing code (probably by using the blob from JS in the Python code, omitting the _read_image function) but it's not my area of knowledge, I'm not sure how far and how fast I can progress. I'll share any findings if I have them.

bedapudi6788 commented 9 months ago

Hey, thanks, i found the issue, testing locally. Will update the version in a bit.

bedapudi6788 commented 9 months ago

https://github.com/notAI-tech/NudeNet/commit/b5cbff72b5503107e670504d683d07168d263875

i think this should fix it. image pre-processing with aspect ratio being preserved is added. new version pushed to pypi.

Solmyr1982 commented 9 months ago

Thank you. It indeed works better, but still not as accurate as the web version, unfortunately. image

bedapudi6788 commented 9 months ago

Hmm, may be some pre procesaing steps still not the same. I will try to see where the difference is.

Solmyr1982 commented 9 months ago

In some cases, the difference is pretty high, with more than 50% probability from the web app and nothing from the Python code: image

bedapudi6788 commented 9 months ago

Could you email me some of the images? If possible? praneeth at bpraneeth.com

Solmyr1982 commented 9 months ago

It's sent.

AlexUrrutia commented 9 months ago

I'm getting the same results using the same image on both tests:

Web results from https://nudenet.notai.tech:

Screen Shot 2023-09-25 at 8 10 41 AM

Local docker (NO GENITALIA DETECTED):

{
    "prediction": [
        [
            {
                "class": "MALE_BREAST_EXPOSED",
                "score": 0.5861272811889648,
                "box": [
                    238,
                    69,
                    73,
                    35
                ]
            },
            {
                "class": "ARMPITS_EXPOSED",
                "score": 0.5417417287826538,
                "box": [
                    371,
                    78,
                    46,
                    22
                ]
            },
            {
                "class": "BELLY_EXPOSED",
                "score": 0.527684211730957,
                "box": [
                    257,
                    104,
                    87,
                    39
                ]
            }
        ]
    ],
    "success": true
}
bedapudi6788 commented 9 months ago

I haven't yet gotten around to debugging this. will need to look at the pre-process ops in details. may be this or coming weeked, I will and update.

AlexUrrutia commented 9 months ago

I haven't yet gotten around to debugging this. will need to look at the pre-process ops in details. may be this or coming weeked, I will and update.

Thank you! it would be great to have same results as your web example :)

AlexUrrutia commented 9 months ago

@bedapudi6788 I sent you a link to test different images and see the results, check your email, thanks!

bedapudi6788 commented 9 months ago

@AlexUrrutia got your email.

not sure why the results are different from https://nudenet.notai.tech/

the web example, runs nudedetector in-browser (i.e: model is loaded in your browser itself in javascript)

https://air.flaxbin.com/aws/live/nudity_check.php

Your webpage is calling the python module in the backend right? There are some pre-processing differences between python and js implementations, which is causing the difference in results.

You will be able to re-produce the same results with the in-browser version

About the difference in pre-processing, I will fix it, i.e: python and js versions working same, when I get some free time.

AlexUrrutia commented 9 months ago

Yes the python module is being called from the webpage, now I understand why the difference, hope it gets fixed soon :) thanks a lot!

mustbeinsane commented 9 months ago

web page code has lower max score / scores than the python implementation so it picks up more. Lower the scores in the python code and you should get same results. that worked for me.

AlexUrrutia commented 9 months ago

web page code has lower max score / scores than the python implementation so it picks up more. Lower the scores in the python code and you should get same results. that worked for me.

Can you provide a sample of the scores you've used? Thanks

bedapudi6788 commented 9 months ago

oh yaa, that might be the reason. in web page it's 0.4 and 0.25. let me check and update the package.

AlexUrrutia commented 9 months ago

oh yaa, that might be the reason. in web page it's 0.4 and 0.25. let me check and update the package.

@bedapudi6788 Please let me know when docker image is updated so I can test again, thanks!

AlexUrrutia commented 9 months ago

@Solmyr1982 Did you get it to work?

AlexUrrutia commented 9 months ago

@bedapudi6788 @mustbeinsane @Solmyr1982 Issues using the latest docker instance:

16962635898568562197 {"prediction": [[{"class": "FEMALE_BREAST_EXPOSED", "score": 0.6442430019378662, "box": [330, 328, 235, 256]}, {"class": "FEMALE_BREAST_EXPOSED", "score": 0.5400181412696838, "box": [336, 588, 254, 318]}]], "success": true}

16962553138329851362 {"prediction": [[{"class": "FACE_FEMALE", "score": 0.6978989243507385, "box": [195, 260, 341, 825]}, {"class": "MALE_GENITALIA_EXPOSED", "score": 0.6808986663818359, "box": [114, 832, 255, 447]}]], "success": true}

and so on ...

You can test it here: LINK

Solmyr1982 commented 9 months ago

@Solmyr1982 Did you get it to work?

Not really, I tried the solution mentioned by @mustbeinsane but it doesn't work, the problem seems to be somewhere else. Hopefully, @bedapudi6788 will have some spare time to debug and find where this difference is coming from.

bedapudi6788 commented 8 months ago

I updated the code with almost same as pre-processing as web version. tried to match it wherever possible. result scores seem to be very close (but not exactly same).

One observation is, the original pytorch results seem better than both. I will add a different version of nudenet based on the pytorch model.

AlexUrrutia commented 8 months ago

I updated the code with almost same as pre-processing as web version. tried to match it wherever possible. result scores seem to be very close (but not exactly same).

One observation is, the original pytorch results seem better than both. I will add a different version of nudenet based on the pytorch model.

Ok cool, I’m going to test the docker container and post results here, thanks!

bedapudi6788 commented 8 months ago

@AlexUrrutia wait 10 min to start your testing. the new docker container based on 3.0.8 is being pushed.

AlexUrrutia commented 8 months ago

@AlexUrrutia wait 10 min to start your testing. the new docker container based on 3.0.8 is being pushed.

OK, I was testing your web demo and I got these results (is that normal?):

Screen Shot 2023-10-11 at 5 55 21 AM Screen Shot 2023-10-11 at 5 55 37 AM
bedapudi6788 commented 8 months ago

yupp, it has false positives sometimes. I am also training a bigger model which will have better accuracy. Will update the repo in couple of days.

Solmyr1982 commented 8 months ago

Thank you for the update @bedapudi6788 . Could you please let me know, regarding the bigger model you mentioned - you mean to share this model in a couple of days?

Just a brief explanation of my situation: I have around 300000 photos, and I have a self-made solution that randomly shows the photos via web browser - as a result, I'm using an old iPad as a digital photo frame (though it could be any device that can run a web browser). Taking into account the number of photos, it's impossible to go through them manually and detect all the unsafe ones. Therefore I started to look for a solution and found yours, which works really well. In my case, I'd rather wait days or weeks for the more precise model since I want to reduce the chance of having any unsafe photo randomly shown in my living room as much as possible. Few more things I noticed while creating a code for crawling through my gallery (probably outside of this bug): If you rotate the photo 90, 180, 270 and try using the NudeDetector on each iteration, it significantly increases the chances of finding an unsafe photo. The downside - you're processing four pictures instead of one, but it worth it. Moreover, if you split the photo into four equal pieces (just by cutting it horizontally and vertically), and try to use NudeNet on each part, it also increases the chances.
As a result, it works a way longer, but the precision is becoming higher. Maybe it could be an option to include those improvements in the NudeNet itself, but as I mentioned, the performance isn't that good.

Dekita commented 8 months ago

any progress on this? i am commonly getting false positives/negatives using the python install, hoping when this is fixed its going to resolve it :)

morteza102030 commented 8 months ago

image image image image image

too many false detect

AlexUrrutia commented 5 months ago

Hello there, any updates on this bug? Thanks for The hard work ✅

RMobile17 commented 4 months ago

no solution yet?

ManuThakur10 commented 4 months ago

Also, if we wanted to train the model further, which model is this using? I see that under docs, there are two files: the yolov8, and something called best.onnx. What model is best.onnx?

wifijt commented 2 months ago

Just going to weigh in on this as well - a few things: I wanted to be able to process HEIC images, I used pillow - but the conversion between HEIC and jpeg causes issues for the detection - not sure if this is the GRB2RGB issue or not - haven't been able to make it work reliably - also some older rotated images are not actuality rotated - I second the re-detection on rotated images to get the best score.

Lastly I second the web version being much more accurate - have been trying to figure it out, but to no luck. Would love to get a python version working with greater accuracy

Serrallonga25 commented 1 month ago

Hello, thanks for the work.

I have discovered what causes the problem, the cv2.resize() function in _read_image, after going through it and saving it as an image, the web version does not detect the same boxes.

I have replaced it with the PIL resize and it works perfectly

def _read_image(image_path, target_size=320):

    if isinstance(image_path, str):
        img = cv2.imread(image_path)
    elif isinstance(image_path, np.ndarray):
        img = image_path
    else:
        raise ValueError('please make sure the image_path is str or np.ndarray')

    img_height, img_width = img.shape[:2]
    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

    aspect = img_width / img_height

    if img_height > img_width:
        new_height = target_size
        new_width = int(round(target_size * aspect))
    else:
        new_width = target_size
        new_height = int(round(target_size / aspect))

    resize_factor = math.sqrt(
        (img_width**2 + img_height**2) / (new_width**2 + new_height**2)
    )

    #img = cv2.resize(img, (new_width, new_height))
    #img = resizetoCV2(img, (new_width, new_height))

    #pad_x = target_size - new_width
    #pad_y = target_size - new_height

    #pad_top, pad_bottom = [int(i) for i in np.floor([pad_y, pad_y]) / 2]
    #pad_left, pad_right = [int(i) for i in np.floor([pad_x, pad_x]) / 2]

    #img = cv2.copyMakeBorder(
    #    img,
    #    pad_top,
    #    pad_bottom,
    #    pad_left,
    #    pad_right,
    #    cv2.BORDER_CONSTANT,
    #    value=[0, 0, 0],
    #)

    pad_left = int((target_size - new_width) /2)
    pad_top = int((target_size - new_height) /2)

    #img = cv2.resize(img, (target_size, target_size))
    img = resizetoCV2(img, (target_size, target_size))

    image_data = img.astype("float32") / 255.0  # normalize
    image_data = np.transpose(image_data, (2, 0, 1))
    image_data = np.expand_dims(image_data, axis=0)

    return image_data, resize_factor, pad_left, pad_top

def resizetoCV2(img_or, new_size):
    if isinstance(img_or, str):
        PILimg = PIL.Image.open(img_or)
    elif isinstance(img_or, np.ndarray):
        PILimg = PIL.Image.fromarray(img_or)

    PILimg.thumbnail(new_size, PIL.Image.Resampling.LANCZOS)
    #PILimg = PILimg.convert('RGB')
    # img = np.array(PILimg)
    # Convert RGB to BGR
    # img =img[:, :, ::-1].copy()
    new_im = PIL.Image.new("RGB", new_size)
    box = tuple((n - o) // 2 for n, o in zip(new_size, PILimg.size))
    new_im.paste(PILimg, box)
    return np.array(new_im)

With this modification it gives me the same results in both environments.

Greetings.

P.S Sorry but my English is not very good and I have to go to Google Translate

bedapudi6788 commented 4 weeks ago

@Serrallonga25 that's awesome! thank you for the insight. I will push an updated version.

AlexUrrutia commented 4 weeks ago

@Serrallonga25 that's awesome! thank you for the insight. I will push an updated version.

Great! Hope to get the fixed version on Docker soon, thanks

AlexUrrutia commented 4 weeks ago

Hello @bedapudi6788 , I'm testing https://nudenet.notai.tech/ vs Python on my server and got this results after using the code provided by @Serrallonga25:

Screenshot 2024-06-04 at 2 40 39 PM

Python output with same image: [ { "box": [217, 194, 267, 303], "class": "FACE_MALE", "score": 0.638852000236511 }, { "box": [4, 738, 607, 533], "class": "MALE_GENITALIA_EXPOSED", "score": 0.574285209178925 } ]

AlexUrrutia commented 4 weeks ago

nudenet.py

from flask import Flask, request, jsonify
import os
import math
import cv2
import numpy as np
import urllib.request
import onnxruntime
from onnxruntime.capi import _pybind_state as C
import PIL.Image
app = Flask(__name__)

__labels = [
    "FEMALE_GENITALIA_COVERED",
    "FACE_FEMALE",
    "BUTTOCKS_EXPOSED",
    "FEMALE_BREAST_EXPOSED",
    "FEMALE_GENITALIA_EXPOSED",
    "MALE_BREAST_EXPOSED",
    "ANUS_EXPOSED",
    "FEET_EXPOSED",
    "BELLY_COVERED",
    "FEET_COVERED",
    "ARMPITS_COVERED",
    "ARMPITS_EXPOSED",
    "FACE_MALE",
    "BELLY_EXPOSED",
    "MALE_GENITALIA_EXPOSED",
    "ANUS_COVERED",
    "FEMALE_BREAST_COVERED",
    "BUTTOCKS_COVERED",
]

def _read_image(image_path, target_size=320):
    if isinstance(image_path, str):
        img = cv2.imread(image_path)
    elif isinstance(image_path, np.ndarray):
        img = image_path
    else:
        raise ValueError('please make sure the image_path is str or np.ndarray')

    img_height, img_width = img.shape[:2]
    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

    aspect = img_width / img_height

    if img_height > img_width:
        new_height = target_size
        new_width = int(round(target_size * aspect))
    else:
        new_width = target_size
        new_height = int(round(target_size / aspect))

    resize_factor = math.sqrt(
        (img_width**2 + img_height**2) / (new_width**2 + new_height**2)
    )

    # pad_left = int((target_size - new_width) / 2)
    # pad_top = int((target_size - new_height) / 2)

    # img = resizetoCV2(img, (target_size, target_size))

    # image_data = img.astype("float32") / 255.0  # normalize
    # image_data = np.transpose(image_data, (2, 0, 1))
    # image_data = np.expand_dims(image_data, axis=0)

    # return image_data, resize_factor, pad_left, pad_top
    pad_left = int((target_size - new_width) /2)
    pad_top = int((target_size - new_height) /2)

    #img = cv2.resize(img, (target_size, target_size))
    img = resizetoCV2(img, (target_size, target_size))

    image_data = img.astype("float32") / 255.0  # normalize
    image_data = np.transpose(image_data, (2, 0, 1))
    image_data = np.expand_dims(image_data, axis=0)

    return image_data, resize_factor, pad_left, pad_top

def resizetoCV2(img_or, new_size):
    if isinstance(img_or, str):
        PILimg = Image.open(img_or)
    elif isinstance(img_or, np.ndarray):
        PILimg = PIL.Image.fromarray(img_or)
    else:
        raise ValueError('please make sure the img_or is str or np.ndarray')

    #PILimg.thumbnail(new_size, Image.LANCZOS)  # Use Image.LANCZOS for compatibility
    PILimg.thumbnail(new_size, PIL.Image.Resampling.LANCZOS)
    new_im = PIL.Image.new("RGB", new_size)
    box = tuple((n - o) // 2 for n, o in zip(new_size, PILimg.size))
    new_im.paste(PILimg, box)
    return np.array(new_im)

def _postprocess(output, resize_factor, pad_left, pad_top):
    outputs = np.transpose(np.squeeze(output[0]))
    rows = outputs.shape[0]
    boxes = []
    scores = []
    class_ids = []

    for i in range(rows):
        classes_scores = outputs[i][4:]
        max_score = np.amax(classes_scores)

        if max_score >= 0.2:
            class_id = np.argmax(classes_scores)
            x, y, w, h = outputs[i][0], outputs[i][1], outputs[i][2], outputs[i][3]
            left = int(round((x - w * 0.5 - pad_left) * resize_factor))
            top = int(round((y - h * 0.5 - pad_top) * resize_factor))
            width = int(round(w * resize_factor))
            height = int(round(h * resize_factor))
            class_ids.append(class_id)
            scores.append(max_score)
            boxes.append([left, top, width, height])

    indices = cv2.dnn.NMSBoxes(boxes, scores, 0.25, 0.45)

    detections = []
    for i in indices:
        box = boxes[i]
        score = scores[i]
        class_id = class_ids[i]
        detections.append(
            {"class": __labels[class_id], "score": float(score), "box": box}
        )

    return detections

class NudeDetector:
    def __init__(self, providers=None):
        self.onnx_session = onnxruntime.InferenceSession(
            os.path.join(os.path.dirname(__file__), "best.onnx"),
            providers=C.get_available_providers() if not providers else providers,
        )
        model_inputs = self.onnx_session.get_inputs()
        input_shape = model_inputs[0].shape
        self.input_width = input_shape[2]  # 320
        self.input_height = input_shape[3]  # 320
        self.input_name = model_inputs[0].name

    def detect(self, image_path):
        preprocessed_image, resize_factor, pad_left, pad_top = _read_image(
            image_path, self.input_width
        )
        outputs = self.onnx_session.run(None, {self.input_name: preprocessed_image})
        detections = _postprocess(outputs, resize_factor, pad_left, pad_top)

        return detections

    def censor(self, image_path, classes=[], output_path=None):
        detections = self.detect(image_path)
        if classes:
            detections = [
                detection for detection in detections if detection["class"] in classes
            ]

        img = cv2.imread(image_path)

        for detection in detections:
            box = detection["box"]
            x, y, w, h = box[0], box[1], box[2], box[3]
            # change these pixels to pure black
            img[y : y + h, x : x + w] = (0, 0, 0)

        if not output_path:
            image_path, ext = os.path.splitext(image_path)
            output_path = f"{image_path}_censored{ext}"

        cv2.imwrite(output_path, img)

        return output_path

@app.route("/detect", methods=["GET"])
def detect_nudity():
    # Check if 'url' parameter is provided
    image_url = request.args.get("url")

    if not image_url:
        return jsonify({"error": "'url' parameter is required"}), 400

    # Download the image from the URL
    try:
        req = urllib.request.urlopen(image_url)
        image_data = req.read()
    except Exception as e:
        return jsonify({"error": f"Failed to download image: {str(e)}"}), 400

    # Save the image temporarily
    file_path = "/tmp/temp_image.jpg"
    with open(file_path, "wb") as f:
        f.write(image_data)

    # Detect nudity
    detector = NudeDetector()
    detections = detector.detect(file_path)

    # Remove the temporary file
    os.remove(file_path)

    return jsonify(detections)

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=3018, debug=True)
AlexUrrutia commented 4 weeks ago

I think the model is outdated, isn't it?

Screenshot 2024-06-04 at 3 47 26 PM
Sascha353 commented 3 weeks ago

I can also confirm if you rotate the image three times (90,180,270 degrees) you get four different results, sometimes better than with the initial orientation. I think this could be solved during training by feeding each image of the dataset in the original and rotated orientation.

bedapudi6788 commented 1 day ago

I can also confirm if you rotate the image three times (90,180,270 degrees) you get four different results, sometimes better than with the initial orientation. I think this could be solved during training by feeding each image of the dataset in the original and rotated orientation.

Pushed new models which are trained with lot of data aug, hopefully that will solve this issue.

@AlexUrrutia have updated the pre-processing to be exactly same as js version. using the exact same function calls as opencv.

Weirdly even the ultralytics official onnx inference code is also wrong.

Appreciate if you can test and let me know any issues

bedapudi6788 commented 1 day ago

@AlexUrrutia I am also in the process of pushing a different type of model (not object detection) as stage 2 for more accuracy, will update here once done.