diff --git a/README.md b/README.md index d9b9ade..c594fe2 100644 --- a/README.md +++ b/README.md @@ -140,9 +140,10 @@ location first (e.g. `go install`, or copy it to `/usr/local/bin`) and re-run - **Orientation (optional, local ML).** When onnxruntime is installed, each photo is run through the [deep-image-orientation-detection](https://huggingface.co/DuarteBarbosa/deep-image-orientation-detection) EfficientNetV2 model (downloaded once, run locally via onnxruntime) to predict - 0°/90°/180°/270°. A rotation is written to the EXIF Orientation tag only when - that rotation wins an outright majority (≥ 0.50), so an already-upright photo - is never flipped. + 0°/90°/180°/270°. The 180° case is treated as impossible — a hand-held camera + is never upside-down — so its probability is dropped and the rest renormalised. + A rotation is written to the EXIF Orientation tag only when it then wins an + outright majority (≥ 0.50), so an already-upright photo is never flipped. - **Video.** `ffmpeg` transcodes to H.264 (CRF 18) + AAC, stamping `creation_time` from the file's modification time (the embedded date is wrong). - **Unmount.** When finished, the camera volume is unmounted via the diff --git a/internal/orient/orient.go b/internal/orient/orient.go index 950d5e0..8e6624d 100644 --- a/internal/orient/orient.go +++ b/internal/orient/orient.go @@ -155,17 +155,28 @@ func preprocess(src image.Image, dst []float32) { } } -// argmaxSoftmax returns the highest-scoring class index and its softmax -// probability. +// class180 (rotate 180°) is treated as impossible: a hand-held camera is +// essentially never upside-down, so excluding it avoids spurious 180° flips and +// concentrates probability on the plausible orientations. +const class180 = 2 + +// argmaxSoftmax returns the highest-scoring plausible class and its softmax +// probability, renormalised as if the 180° class did not exist. func argmaxSoftmax(logits []float32) (int, float64) { - maxIdx, maxLogit := 0, logits[0] + maxIdx, maxLogit := -1, float32(0) for i, v := range logits { - if v > maxLogit { + if i == class180 { + continue + } + if maxIdx == -1 || v > maxLogit { maxIdx, maxLogit = i, v } } var sum float64 - for _, v := range logits { + for i, v := range logits { + if i == class180 { + continue + } sum += math.Exp(float64(v - maxLogit)) } return maxIdx, 1.0 / sum diff --git a/internal/orient/orient_test.go b/internal/orient/orient_test.go index 1f0bb71..ac7b174 100644 --- a/internal/orient/orient_test.go +++ b/internal/orient/orient_test.go @@ -18,6 +18,19 @@ func TestArgmaxSoftmax(t *testing.T) { } } +func TestArgmaxSoftmax_Never180(t *testing.T) { + // Even when the 180° class dominates the logits, it must never be selected, + // nor counted in the confidence denominator. + idx, conf := argmaxSoftmax([]float32{1, 0, 5, 0}) + if idx == class180 || classToOrientation[idx] == 3 { + t.Errorf("180° must never be selected, got class %d", idx) + } + // Renormalised over {0,1,3}: class 0 wins with exp(1)/(exp(1)+exp(0)+exp(0)). + if conf < 0.5 { + t.Errorf("confidence should be renormalised over plausible classes, got %.3f", conf) + } +} + func TestClassMapping(t *testing.T) { // 0°->1 (normal), 90°CW->6, 180°->3, 90°CCW->8. want := [4]int{1, 6, 3, 8}