├── .dockerignore ├── heroku.yml ├── .gitignore ├── fixtures ├── filled.png ├── forest.jpg └── big_boobs.cropped.png ├── .gitmodules ├── hooks └── post_checkout ├── app.json ├── go.mod ├── docker ├── test │ └── docker-compose.yml └── dev │ └── docker-compose.yml ├── Dockerfile.heroku ├── main.go ├── helpers.go ├── router.go ├── scoring.go ├── file.go ├── Readme.md ├── go.sum ├── Dockerfile ├── handlers_test.go ├── handlers.go └── anAlgorithm.go /.dockerignore: -------------------------------------------------------------------------------- 1 | /.git -------------------------------------------------------------------------------- /heroku.yml: -------------------------------------------------------------------------------- 1 | build: 2 | docker: 3 | web: Dockerfile.heroku -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /tmp 2 | /.idea 3 | adult-image-detector 4 | rest-api.http 5 | vendor/*/ 6 | uploads/* -------------------------------------------------------------------------------- /fixtures/filled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-dating/adult-image-detector/HEAD/fixtures/filled.png -------------------------------------------------------------------------------- /fixtures/forest.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-dating/adult-image-detector/HEAD/fixtures/forest.jpg -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "models/open_nsfw"] 2 | path = models/open_nsfw 3 | url = https://github.com/yahoo/open_nsfw 4 | -------------------------------------------------------------------------------- /fixtures/big_boobs.cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-dating/adult-image-detector/HEAD/fixtures/big_boobs.cropped.png -------------------------------------------------------------------------------- /hooks/post_checkout: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Docker hub does a recursive clone, then checks the branch out, 3 | # so when a PR adds a submodule (or updates it), it fails. 4 | git submodule update --init -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "adult-image-detector", 3 | "description": "Use deep neural networks and other algos for detect nude images", 4 | "repository": "https://github.com/open-dating/adult-image-detector", 5 | "stack": "container", 6 | "success_url": "/" 7 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.zerodha.tech/common/nsfw-image-detector 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/caarlos0/env v3.5.1-0.20181024121956-515df9e9ee27+incompatible 7 | github.com/google/uuid v1.1.2-0.20190416172445-c2e93f3ae59f 8 | github.com/pdfcpu/pdfcpu v0.3.9 9 | github.com/stretchr/testify v1.7.0 // indirect 10 | gocv.io/x/gocv v0.26.0 11 | ) 12 | -------------------------------------------------------------------------------- /docker/test/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | go.adult-image-detector-test: 4 | container_name: adult-image-detector-go-test 5 | build: 6 | context: ../../ 7 | dockerfile: Dockerfile 8 | args: 9 | tests: "skip_on_build" 10 | volumes: 11 | - ../../:/go/src/adult-image-detector 12 | command: "sh -c 'go test'" -------------------------------------------------------------------------------- /docker/dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | go.adult-image-detector-dev: 4 | container_name: adult-image-detector-go-dev 5 | ports: 6 | - "9191:9191" 7 | build: 8 | context: ../../ 9 | dockerfile: Dockerfile 10 | args: 11 | tests: "skip_on_build" 12 | volumes: 13 | - ../../:/go/src/adult-image-detector 14 | command: "sh -c 'fresh'" -------------------------------------------------------------------------------- /Dockerfile.heroku: -------------------------------------------------------------------------------- 1 | # using precompiled version due to heroku has 2 | # docker compile timeout=15min 3 | FROM opendating/adult-image-detector:0.4.0 4 | 5 | RUN go install github.com/pilu/fresh@0fa698148017fa2234856bdc881d9cc62517f62b 6 | 7 | WORKDIR $GOPATH/src/adult-image-detector 8 | 9 | COPY ./ ./ 10 | 11 | ARG tests 12 | RUN if test $tests = 'skip_on_build'; then \ 13 | echo "run tests skipped!"; \ 14 | else \ 15 | echo "run tests!"; \ 16 | go test; \ 17 | fi 18 | 19 | RUN go mod tidy && go build 20 | 21 | EXPOSE 9191 22 | 23 | CMD ["/go/src/adult-image-detector/nsfw-image-detector"] 24 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/caarlos0/env" 8 | ) 9 | 10 | // VERSION of the service. 11 | const VERSION = "0.4.0" 12 | 13 | type params struct { 14 | Port string `env:"PORT" envDefault:"9191"` 15 | CorsOrigin string `env:"CORS_ORIGIN" envDefault:"*"` 16 | } 17 | 18 | func main() { 19 | var cfg params 20 | 21 | log.Println("App version", VERSION) 22 | 23 | if err := env.Parse(&cfg); err != nil { 24 | log.Printf("%+v\n", err) 25 | } 26 | 27 | log.Printf("Server run at http://localhost:%s", cfg.Port) 28 | 29 | if err := http.ListenAndServe(":"+cfg.Port, &router{cfg: cfg}); err != nil { 30 | panic(err) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | 7 | "gocv.io/x/gocv" 8 | ) 9 | 10 | // convert rgb to ycbr 11 | func rgbaToYCbCr(pixCol color.Color) (y int, cb int, cr int) { 12 | rInt, gInt, bInt, _ := pixCol.RGBA() 13 | 14 | // y8, cb8, cr8 := color.RGBToYCbCr(uint8(rInt), uint8(gInt), uint8(bInt)) 15 | // return int(y8), int(cb8), int(cr8) 16 | 17 | r := float32(rInt / 256) 18 | g := float32(gInt / 256) 19 | b := float32(bInt / 256) 20 | 21 | return int(16.0 + 0.256788*r + 0.504129*g + 0.097905*b), 22 | int(128.0 - 0.148223*r - 0.290992*g + 0.439215*b), 23 | int(128.0 + 0.439215*r - 0.367788*g - 0.071427*b) 24 | } 25 | 26 | // Convert image to gocv.Mat 27 | // https://github.com/hybridgroup/gocv/issues/228 28 | func imageToRGB8Mat(img image.Image) (gocv.Mat, error) { 29 | bounds := img.Bounds() 30 | x := bounds.Dx() 31 | y := bounds.Dy() 32 | bytes := make([]byte, 0, x*y*3) 33 | 34 | //don't get surprised of reversed order everywhere below 35 | for j := bounds.Min.Y; j < bounds.Max.Y; j++ { 36 | for i := bounds.Min.X; i < bounds.Max.X; i++ { 37 | r, g, b, _ := img.At(i, j).RGBA() 38 | bytes = append(bytes, byte(b>>8), byte(g>>8), byte(r>>8)) 39 | } 40 | } 41 | return gocv.NewMatFromBytes(y, x, gocv.MatTypeCV8UC3, bytes) 42 | } 43 | -------------------------------------------------------------------------------- /router.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // router routes requests 9 | type router struct { 10 | cfg params 11 | } 12 | 13 | // handler http responses 14 | func (rtr *router) ServeHTTP(w http.ResponseWriter, r *http.Request) { 15 | switch r.URL.Path { 16 | case "/api/v1/detect": 17 | switch r.Method { 18 | case http.MethodPost: 19 | // allow ajax reponses from browser 20 | w.Header().Set("Access-Control-Allow-Origin", rtr.cfg.CorsOrigin) 21 | 22 | proceedImage(w, r) 23 | default: 24 | ShowImageForm(w) 25 | } 26 | 27 | case "/api/v1/pdf_detect": 28 | switch r.Method { 29 | case http.MethodPost: 30 | w.Header().Set("Access-Control-Allow-Origin", rtr.cfg.CorsOrigin) 31 | proceedPDF(w, r) 32 | case http.MethodGet: 33 | ShowPDFForm(w) 34 | default: 35 | HandleError(w, fmt.Errorf("bad request. make http POST request instead")) 36 | return 37 | } 38 | 39 | default: 40 | HandleError(w, fmt.Errorf(` 41 | 42 |
43 | Bad request. Invalid endpoint. Avalaibled endpoints: 44 |236 | curl -i -X POST -F "image=@Daddy_Lets_Me_Ride_His_Cock_preview_720p.mp4.jpg" http://localhost:9191/api/v1/detect 237 |238 | 239 | 240 | ` 241 | w.Write([]byte(form)) 242 | } 243 | 244 | // ShowPDFForm to upload pdf file. 245 | func ShowPDFForm(w http.ResponseWriter) { 246 | form := ` 247 | 248 | 249 | 268 |
269 | curl -i -X POST -F "image=@Daddy_Lets_Me_Ride_His_Cock_preview_720p.mp4.pdf" http://localhost:9191/api/v1/pdf_detect 270 |271 | 272 | 273 | ` 274 | w.Write([]byte(form)) 275 | } 276 | -------------------------------------------------------------------------------- /anAlgorithm.go: -------------------------------------------------------------------------------- 1 | // An Algorithm for Nudity Detection 2 | // by Rigan Ap-apid 3 | // http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.96.9872&rep=rep1&type=pdf 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "image" 9 | "image/color" 10 | "image/draw" 11 | "sort" 12 | 13 | "gocv.io/x/gocv" 14 | ) 15 | 16 | const ( 17 | skinCbMin = 80 18 | skinCbMax = 120 19 | skinCrMin = 133 20 | skinCrMax = 173 21 | ) 22 | 23 | type anAlgorithm struct { 24 | img image.Image 25 | height int 26 | width int 27 | skinMap image.Image 28 | backgroundPixelCount int 29 | skinPixelCount int 30 | regions []anAlgorithmRegion 31 | regionsContours [][]image.Point 32 | boundsPoly anAlgorithmBoundsPolygon 33 | debug bool 34 | } 35 | 36 | type anAlgorithmRegion struct { 37 | area float64 38 | contour []image.Point 39 | } 40 | 41 | type anAlgorithmBoundsPolygon struct { 42 | area float64 43 | contour []image.Point 44 | hue float64 45 | skinPixels []color.Color 46 | skinPixelCount int 47 | avgSkinsIntensity float64 48 | image image.Image 49 | height int 50 | width int 51 | } 52 | 53 | // find skins on image and create mask with white/black regions 54 | // do manually, becouse have some trouble with gocv.InRange https://github.com/hybridgroup/gocv/issues/159 55 | func (a *anAlgorithm) maskSkinAndCountSkinPixels() { 56 | upLeft := image.Point{0, 0} 57 | lowRight := image.Point{a.width, a.height} 58 | 59 | a.skinMap = image.NewRGBA(image.Rectangle{upLeft, lowRight}) 60 | 61 | black := color.RGBA{0, 0, 0, 0xff} 62 | white := color.RGBA{255, 255, 255, 0xff} 63 | 64 | a.backgroundPixelCount = 0 65 | a.skinPixelCount = 0 66 | 67 | var drawPixCol color.RGBA 68 | for x := 0; x < a.width; x++ { 69 | for y := 0; y < a.height; y++ { 70 | pixCol := a.img.At(x, y) 71 | 72 | drawPixCol = white 73 | if a.yCbCrSkinDetector(pixCol) == true { 74 | a.skinPixelCount++ 75 | drawPixCol = black 76 | } else { 77 | a.backgroundPixelCount++ 78 | } 79 | 80 | a.skinMap.(draw.Image).Set(x, y, drawPixCol) 81 | } 82 | } 83 | 84 | // out, _ := os.Create("uploads/0.maskSkinAndCountSkinPixels.jpg") 85 | // jpeg.Encode(out, a.skinMap, nil) 86 | } 87 | 88 | func (a *anAlgorithm) findRegions() { 89 | img, _ := imageToRGB8Mat(a.skinMap) 90 | 91 | mask := gocv.NewMat() 92 | gocv.InRangeWithScalar(img, gocv.NewScalar(0.0, 0.0, 0.0, 0.0), gocv.NewScalar(1.0, 1.0, 1.0, 0.0), &mask) 93 | 94 | contours := gocv.FindContours(mask, gocv.RetrievalList, gocv.ChainApproxNone) 95 | a.regionsContours = contours 96 | 97 | for i := range contours { 98 | a.regions = append(a.regions, anAlgorithmRegion{ 99 | area: gocv.ContourArea(contours[i]), 100 | contour: contours[i], 101 | }) 102 | } 103 | 104 | sort.Slice(a.regions, func(i, j int) bool { 105 | return a.regions[i].area > a.regions[j].area 106 | }) 107 | 108 | // if we havent 2rd region 109 | if len(a.regions) == 1 { 110 | a.regions = append(a.regions, a.regions[0]) 111 | } 112 | 113 | // if we havent 3rd region 114 | if len(a.regions) == 2 { 115 | a.regions = append(a.regions, a.regions[1]) 116 | } 117 | } 118 | 119 | // Identify the leftmost, the uppermost, the rightmost, 120 | // and the lowermost skin pixels of the three largest 121 | // skin regions. Use these points as the corner points 122 | func (a *anAlgorithm) findBoundsPolyCorners() { 123 | leftmost := a.width 124 | uppermost := a.height 125 | rightmost := 0 126 | lowermost := 0 127 | 128 | for i, region := range a.regions { 129 | if i > 2 { 130 | break 131 | } 132 | 133 | // find corners 134 | for _, p := range region.contour { 135 | if p.X < leftmost { 136 | leftmost = p.X 137 | } 138 | if p.X > rightmost { 139 | rightmost = p.X 140 | } 141 | if p.Y < uppermost { 142 | uppermost = p.Y 143 | } 144 | if p.Y > lowermost { 145 | lowermost = p.Y 146 | } 147 | } 148 | } 149 | 150 | width := rightmost - leftmost 151 | height := lowermost - uppermost 152 | 153 | a.boundsPoly = anAlgorithmBoundsPolygon{ 154 | area: float64(width * height), 155 | contour: []image.Point{ 156 | {X: leftmost, Y: uppermost}, 157 | {X: rightmost, Y: lowermost}, 158 | }, 159 | skinPixelCount: 0, 160 | } 161 | 162 | upLeft := image.Point{0, 0} 163 | lowRight := image.Point{width, height} 164 | a.boundsPoly.image = image.NewRGBA(image.Rectangle{upLeft, lowRight}) 165 | } 166 | 167 | // create poly from images, cacl pixel count 168 | // and save pixels fro find avg in we need it 169 | func (a *anAlgorithm) createBoundsPolyAndCalcSkins() { 170 | 171 | xBig := 0 172 | yBig := 0 173 | 174 | for x := a.boundsPoly.contour[0].X; x < a.boundsPoly.contour[1].X; x++ { 175 | for y := a.boundsPoly.contour[0].Y; y < a.boundsPoly.contour[1].Y; y++ { 176 | pixCol := a.img.At(x, y) 177 | 178 | if a.yCbCrSkinDetector(pixCol) == true { 179 | a.boundsPoly.skinPixelCount++ 180 | a.boundsPoly.skinPixels = append(a.boundsPoly.skinPixels, pixCol) 181 | } 182 | 183 | a.boundsPoly.image.(draw.Image).Set(xBig, yBig, pixCol) 184 | 185 | yBig++ 186 | } 187 | yBig = 0 188 | xBig++ 189 | } 190 | 191 | // out, _ := os.Create("uploads/5.createBoundsPolyAndCalcSkins.jpg") 192 | // jpeg.Encode(out, a.boundsPoly.image, nil) 193 | } 194 | 195 | // detect is skin 196 | func (a *anAlgorithm) yCbCrSkinDetector(pixCol color.Color) bool { 197 | _, cb, cr := rgbaToYCbCr(pixCol) 198 | 199 | return cb >= skinCbMin && cb <= skinCbMax && cr >= skinCrMin && cr <= skinCrMax 200 | } 201 | 202 | // find avg intensity in boundsPoly skins region 203 | func (a *anAlgorithm) findAverageSkinsIntensityInBoundsPoly() { 204 | 205 | a.boundsPoly.avgSkinsIntensity = 0 206 | 207 | skinsLen := len(a.boundsPoly.skinPixels) 208 | if skinsLen == 0 { 209 | return 210 | } 211 | 212 | var ( 213 | cbSum int 214 | crSum int 215 | ) 216 | 217 | for i := 0; i < skinsLen; i++ { 218 | _, cb, cr := rgbaToYCbCr(a.boundsPoly.skinPixels[i]) 219 | 220 | cbSum += cb 221 | crSum += cr 222 | } 223 | 224 | avgColorVal := float64((cbSum + crSum) / skinsLen) 225 | if avgColorVal == 0 { 226 | return 227 | } 228 | 229 | a.boundsPoly.avgSkinsIntensity = float64(skinCbMax-skinCbMin+skinCrMax-skinCrMin) / avgColorVal 230 | } 231 | 232 | // return is nude image or not 233 | func (a *anAlgorithm) IsNude() (bool, error) { 234 | a.width = a.img.Bounds().Max.X 235 | a.height = a.img.Bounds().Max.Y 236 | 237 | a.maskSkinAndCountSkinPixels() 238 | 239 | totalPixelCount := a.skinPixelCount + a.backgroundPixelCount 240 | 241 | totalSkinPortion := float32(a.skinPixelCount) / float32(totalPixelCount) 242 | 243 | if totalPixelCount == 0 { 244 | if a.debug { 245 | fmt.Println("No pixels found") 246 | } 247 | return false, nil 248 | } 249 | 250 | // Criteria (a) 251 | if a.debug { 252 | fmt.Println("a: totalSkinPortion=", totalSkinPortion, " < 0.15") 253 | } 254 | if totalSkinPortion < 0.15 { 255 | return false, nil 256 | } 257 | 258 | a.findRegions() 259 | largestRegionPortion := 0.0 260 | nextRegionPortion := 0.0 261 | thirdRegionPortion := 0.0 262 | 263 | if len(a.regions) > 0 { 264 | largestRegionPortion = a.regions[0].area / float64(a.skinPixelCount) 265 | nextRegionPortion = a.regions[1].area / float64(a.skinPixelCount) 266 | thirdRegionPortion = a.regions[2].area / float64(a.skinPixelCount) 267 | } 268 | 269 | // Criteria (b) 270 | if a.debug { 271 | fmt.Println("b: largestRegionPortion=", largestRegionPortion, " < 0.35 && nextRegionPortion=", nextRegionPortion, " < 0.30 && thirdRegionPortion=", thirdRegionPortion, " < 0.30") 272 | } 273 | if largestRegionPortion < 0.35 && nextRegionPortion < 0.30 && thirdRegionPortion < 0.30 { 274 | return false, nil 275 | } 276 | 277 | // Criteria (c) 278 | if a.debug { 279 | fmt.Println("c: largestRegionPortion=", largestRegionPortion, " < 0.45") 280 | } 281 | if largestRegionPortion < 0.45 { 282 | return false, nil 283 | } 284 | 285 | // Criteria (d) 286 | a.findBoundsPolyCorners() 287 | a.createBoundsPolyAndCalcSkins() 288 | 289 | if a.debug { 290 | fmt.Println("d: totalSkinPortion=", totalSkinPortion, " < 0.30") 291 | } 292 | if totalSkinPortion < 0.30 { 293 | boundsPolySkinPortion := float64(a.boundsPoly.skinPixelCount) / a.boundsPoly.area 294 | 295 | if a.debug { 296 | fmt.Println("d: boundsPolySkinPortion=", boundsPolySkinPortion, " < 0.55") 297 | } 298 | if boundsPolySkinPortion < 0.55 { 299 | return false, nil 300 | } 301 | } 302 | 303 | // Criteria (e) 304 | if a.debug { 305 | fmt.Println("e: len(a.regions)=", len(a.regions), " > 60") 306 | } 307 | if len(a.regions) > 60 { 308 | a.findAverageSkinsIntensityInBoundsPoly() 309 | 310 | if a.debug { 311 | fmt.Println("e: boundsPoly.avgSkinsIntensity=", a.boundsPoly.avgSkinsIntensity, " < 0.25") 312 | } 313 | if a.boundsPoly.avgSkinsIntensity < 0.25 { 314 | return false, nil 315 | } 316 | } 317 | 318 | return true, nil 319 | } 320 | --------------------------------------------------------------------------------