Skip to content

Commit 67037a8

Browse files
author
Leandro
committed
Rename Classify to Identify/IdentifyMultiples; add sentinel errors
Aligns with go-face's Identify/IdentifyThreshold rename (breaking, no alias) and biometric terminology (Detect -> Recognize -> Compare -> Identify). Also replaces string-matched error messages with exported sentinel errors (ErrNoFace, ErrNotSingleFace, ErrNoMatch, ErrDatasetFileNotFound) checkable via errors.Is, and fixes a stale doc comment on Identify that claimed it returns an empty list on no match (it returns ErrNoMatch). Requires go-face v1.1.0.
1 parent 54bef48 commit 67037a8

6 files changed

Lines changed: 150 additions & 46 deletions

File tree

README.md

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Face detection and recognition for Go, built on top of [dlib](http://dlib.net)
44
via [go-face](https://github.com/leandroveronezi/go-face). It wraps the
55
lower-level go-face API into a small, batteries-included `Recognizer` type:
6-
load a photo, find faces, classify them against a labeled dataset, and draw
6+
load a photo, find faces, identify them against a labeled dataset, and draw
77
the results back onto the image — in a handful of method calls.
88

99
[![CI](https://github.com/leandroveronezi/go-recognizer/actions/workflows/ci.yml/badge.svg)](https://github.com/leandroveronezi/go-recognizer/actions/workflows/ci.yml)
@@ -15,10 +15,10 @@ the results back onto the image — in a handful of method calls.
1515
## Features
1616

1717
- **Detection** — find one or many faces in an image, sorted left to right.
18-
- **Recognition**classify detected faces against a dataset of known people.
19-
- **Incremental dataset updates**`AddImageToDataset` keeps the classifier in
18+
- **Recognition**identify detected faces against a dataset of known people.
19+
- **Incremental dataset updates**`AddImageToDataset` keeps the identifier in
2020
sync as each face is added; no need to rebuild the whole sample set.
21-
- **Match distance/confidence**`Classify`/`ClassifyMultiples` return the
21+
- **Match distance/confidence**`Identify`/`IdentifyMultiples` return the
2222
matched face's `Distance` and a normalized `Confidence` score, not just an ID.
2323
- **Landmarks** — detected faces carry their `Shapes` (facial landmark
2424
points). Defaults to 5 points (eye corners, nose base); set
@@ -40,6 +40,10 @@ the results back onto the image — in a handful of method calls.
4040
- **Dataset persistence** — save/load known faces to/from a JSON file.
4141
- **Drawing helpers** — annotate the source image with boxes, labels, and
4242
landmark points for the faces found.
43+
- **Typed errors**`AddImageToDataset`/`RecognizeSingle`/`Identify`/
44+
`LoadDataset` return sentinel errors (`ErrNoFace`, `ErrNotSingleFace`,
45+
`ErrNoMatch`, `ErrDatasetFileNotFound`) checkable with `errors.Is`,
46+
instead of matching on error text. See [Errors](#errors) below.
4347

4448
## Requirements
4549

@@ -244,9 +248,9 @@ func main() {
244248
addFile(&rec, filepath.Join(fotosDir, "leonard.jpg"), "Leonard")
245249

246250
// No rec.SetSamples() call needed here: AddImageToDataset already
247-
// keeps the classifier in sync incrementally as each face is added.
251+
// keeps the identifier in sync incrementally as each face is added.
248252

249-
faces, err := rec.ClassifyMultiples(filepath.Join(fotosDir, "elenco3.jpg"))
253+
faces, err := rec.IdentifyMultiples(filepath.Join(fotosDir, "elenco3.jpg"))
250254

251255
if err != nil {
252256
fmt.Println(err)
@@ -340,6 +344,36 @@ face-descriptor (ResNet) and CNN detector model files respectively —
340344
useful if you're using differently-named or fine-tuned dlib models. All
341345
three must be set before calling `Init`; they're read once, at load time.
342346

347+
## Errors
348+
349+
The "expected" failure conditions -- no face detected, more than one
350+
face detected, no dataset match, a missing dataset file -- are exposed
351+
as sentinel errors, so callers can branch on them with `errors.Is`
352+
instead of matching on the error message text (which isn't part of the
353+
API contract and may change):
354+
355+
```go
356+
faces, err := rec.Identify(path)
357+
switch {
358+
case errors.Is(err, recognizer.ErrNotSingleFace):
359+
// the image doesn't have exactly one face
360+
case errors.Is(err, recognizer.ErrNoMatch):
361+
// no Dataset entry within Tolerance
362+
case err != nil:
363+
// something else went wrong (I/O, decode, ...)
364+
}
365+
```
366+
367+
| Error | Returned by |
368+
|-------|-------------|
369+
| `ErrNoFace` | `AddImageToDataset`, when the image has no detected face |
370+
| `ErrNotSingleFace` | `AddImageToDataset`, `RecognizeSingle`, `Identify`, when the image has more than one detected face (or, for `RecognizeSingle`/`Identify`, doesn't have exactly one) |
371+
| `ErrNoMatch` | `Identify`, when the face doesn't match any `Dataset` entry within `Tolerance` |
372+
| `ErrDatasetFileNotFound` | `LoadDataset`, when `Path` doesn't exist |
373+
374+
Any other error (I/O, image decoding, etc.) is wrapped with `%w`, so
375+
`errors.Unwrap`/`errors.As` still reach the underlying cause.
376+
343377
## Contributing
344378

345379
Issues and pull requests are welcome. If you're reporting a build problem,

dataset.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package recognizer
22

33
import (
44
"encoding/json"
5-
"errors"
65
"os"
76
)
87

@@ -32,14 +31,17 @@ func (_this *Recognizer) SaveDataset(Path string) error {
3231
/*
3332
LoadDataset loads the data from the json file into the Dataset.
3433
35-
Call SetSamples afterward: as with AddImageToDataset, Classify and
36-
ClassifyMultiples won't see the loaded entries until SetSamples runs
34+
Returns ErrDatasetFileNotFound if Path doesn't exist -- check with
35+
errors.Is.
36+
37+
Call SetSamples afterward: as with AddImageToDataset, Identify and
38+
IdentifyMultiples won't see the loaded entries until SetSamples runs
3739
again.
3840
*/
3941
func (_this *Recognizer) LoadDataset(Path string) error {
4042

4143
if !fileExists(Path) {
42-
return errors.New("file not found")
44+
return ErrDatasetFileNotFound
4345
}
4446

4547
file, err := os.Open(Path)

examples/recognition/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ const dataDir = "models"
1212

1313
func addFile(rec *recognizer.Recognizer, Path, Id string) {
1414

15+
// AddImageToDataset returns recognizer.ErrNoFace/ErrNotSingleFace for
16+
// images that don't have exactly one face -- check with errors.Is if
17+
// you need to tell those apart; here we just log whatever comes back.
1518
err := rec.AddImageToDataset(Path, Id)
1619

1720
if err != nil {
@@ -47,7 +50,7 @@ func main() {
4750
// No rec.SetSamples() call needed here: AddImageToDataset already
4851
// keeps the classifier in sync incrementally as each face is added.
4952

50-
faces, err := rec.ClassifyMultiples(filepath.Join(fotosDir, "elenco3.jpg"))
53+
faces, err := rec.IdentifyMultiples(filepath.Join(fotosDir, "elenco3.jpg"))
5154

5255
if err != nil {
5356
fmt.Println(err)

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ require (
88
golang.org/x/image v0.44.0
99
)
1010

11-
require github.com/leandroveronezi/go-face v1.0.7
11+
require github.com/leandroveronezi/go-face v1.1.0

recognizer.go

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,24 @@ import (
1212
goFace "github.com/leandroveronezi/go-face"
1313
)
1414

15+
// Sentinel errors for the "expected" failure conditions -- check for
16+
// these with errors.Is instead of matching on the error message text,
17+
// which isn't part of the API contract and may change.
18+
var (
19+
// ErrNoFace is returned when an image has no detected face, where an
20+
// operation requires at least one.
21+
ErrNoFace = errors.New("not a face on the image")
22+
// ErrNotSingleFace is returned when an image doesn't have exactly
23+
// one detected face, where an operation requires exactly one.
24+
ErrNotSingleFace = errors.New("not a single face on the image")
25+
// ErrNoMatch is returned by Identify when the descriptor doesn't
26+
// match any Dataset entry within Tolerance.
27+
ErrNoMatch = errors.New("can't identify")
28+
// ErrDatasetFileNotFound is returned by LoadDataset when Path
29+
// doesn't exist.
30+
ErrDatasetFileNotFound = errors.New("file not found")
31+
)
32+
1533
// Data descriptor of the human face.
1634
type Data struct {
1735
Id string
@@ -27,11 +45,11 @@ type Face struct {
2745
Shapes []image.Point
2846
// Distance is the squared Euclidean distance between this face's
2947
// descriptor and the matched Dataset entry's descriptor. Only set by
30-
// Classify/ClassifyMultiples; zero otherwise.
48+
// Identify/IdentifyMultiples; zero otherwise.
3149
Distance float64
3250
// Confidence is a convenience score in [0,1], normalized as
3351
// 1-Distance/Tolerance -- not a calibrated probability. Only set by
34-
// Classify/ClassifyMultiples; zero otherwise.
52+
// Identify/IdentifyMultiples; zero otherwise.
3553
Confidence float64
3654
}
3755

@@ -143,6 +161,9 @@ func (_this *Recognizer) detect(Path string) ([]goFace.Face, error) {
143161
/*
144162
AddImageToDataset add a sample image to the dataset.
145163
164+
Returns ErrNoFace if the image has no detected face, or ErrNotSingleFace
165+
if it has more than one -- check with errors.Is.
166+
146167
The new entry is appended to the underlying classifier immediately (via
147168
goFace.AppendSample), so it's classifiable right away -- no need to call
148169
SetSamples afterward. SetSamples is still required after LoadDataset or
@@ -158,11 +179,11 @@ func (_this *Recognizer) AddImageToDataset(Path string, Id string) error {
158179
}
159180

160181
if len(faces) == 0 {
161-
return errors.New("Not a face on the image")
182+
return ErrNoFace
162183
}
163184

164185
if len(faces) > 1 {
165-
return errors.New("Not a single face on the image")
186+
return ErrNotSingleFace
166187
}
167188

168189
f := Data{}
@@ -207,7 +228,8 @@ func (_this *Recognizer) SetSamples() {
207228
}
208229

209230
/*
210-
RecognizeSingle returns face if it's the only face on the image or nil otherwise.
231+
RecognizeSingle returns the face on the image, or ErrNotSingleFace if it
232+
doesn't have exactly one -- check with errors.Is.
211233
*/
212234
func (_this *Recognizer) RecognizeSingle(Path string) (goFace.Face, error) {
213235

@@ -218,7 +240,7 @@ func (_this *Recognizer) RecognizeSingle(Path string) (goFace.Face, error) {
218240

219241
pixels, width, height, lerr := _this.loadPixels(Path)
220242
if lerr != nil {
221-
return goFace.Face{}, lerr
243+
return goFace.Face{}, fmt.Errorf("can't recognize: %w", lerr)
222244
}
223245

224246
if _this.UseCNN {
@@ -238,11 +260,11 @@ func (_this *Recognizer) RecognizeSingle(Path string) (goFace.Face, error) {
238260
}
239261

240262
if err != nil {
241-
return goFace.Face{}, fmt.Errorf("Can't recognize: %v", err)
263+
return goFace.Face{}, fmt.Errorf("can't recognize: %w", err)
242264

243265
}
244266
if idFace == nil {
245-
return goFace.Face{}, fmt.Errorf("Not a single face on the image")
267+
return goFace.Face{}, ErrNotSingleFace
246268
}
247269

248270
return *idFace, nil
@@ -259,37 +281,41 @@ func (_this *Recognizer) RecognizeMultiples(Path string) ([]goFace.Face, error)
259281
idFaces, err := _this.detect(Path)
260282

261283
if err != nil {
262-
return nil, fmt.Errorf("Can't recognize: %v", err)
284+
return nil, fmt.Errorf("can't recognize: %w", err)
263285
}
264286

265287
return idFaces, nil
266288

267289
}
268290

269291
/*
270-
Classify returns all faces identified in the image. Empty list is returned if no match.
292+
Identify returns the single face identified in the image.
293+
294+
Returns ErrNotSingleFace if the image doesn't have exactly one face, or
295+
ErrNoMatch if the face doesn't match any Dataset entry within Tolerance
296+
-- check with errors.Is.
271297
272298
Matches against the sample set from the most recent SetSamples call, not
273299
necessarily the current Dataset -- see SetSamples.
274300
*/
275-
func (_this *Recognizer) Classify(Path string) ([]Face, error) {
301+
func (_this *Recognizer) Identify(Path string) ([]Face, error) {
276302

277303
face, err := _this.RecognizeSingle(Path)
278304

279305
if err != nil {
280306
return nil, err
281307
}
282308

283-
personID := _this.rec.ClassifyThreshold(face.Descriptor, _this.Tolerance)
309+
personID := _this.rec.IdentifyThreshold(face.Descriptor, _this.Tolerance)
284310
if personID < 0 {
285-
return nil, fmt.Errorf("Can't classify")
311+
return nil, ErrNoMatch
286312
}
287313

288314
_this.mu.RLock()
289315
defer _this.mu.RUnlock()
290316

291317
if personID >= len(_this.Dataset) {
292-
return nil, fmt.Errorf("Can't classify")
318+
return nil, ErrNoMatch
293319
}
294320

295321
matched := _this.Dataset[personID]
@@ -310,24 +336,26 @@ func (_this *Recognizer) Classify(Path string) ([]Face, error) {
310336
}
311337

312338
/*
313-
ClassifyMultiples returns all faces identified in the image. Empty list is returned if no match.
339+
IdentifyMultiples returns every face identified in the image. Faces with
340+
no Dataset entry within Tolerance are skipped -- an empty slice (not an
341+
error) is returned if none matched.
314342
315343
Matches against the sample set from the most recent SetSamples call, not
316344
necessarily the current Dataset -- see SetSamples.
317345
*/
318-
func (_this *Recognizer) ClassifyMultiples(Path string) ([]Face, error) {
346+
func (_this *Recognizer) IdentifyMultiples(Path string) ([]Face, error) {
319347

320348
faces, err := _this.RecognizeMultiples(Path)
321349

322350
if err != nil {
323-
return nil, fmt.Errorf("Can't recognize: %v", err)
351+
return nil, err
324352
}
325353

326354
facesRec := make([]Face, 0)
327355

328356
for _, f := range faces {
329357

330-
personID := _this.rec.ClassifyThreshold(f.Descriptor, _this.Tolerance)
358+
personID := _this.rec.IdentifyThreshold(f.Descriptor, _this.Tolerance)
331359
if personID < 0 {
332360
continue
333361
}

0 commit comments

Comments
 (0)