Files
Loki/internal/loki/web_upload_orient.go
T
Claude 00b2350fb9 Navigateur piloté, images redressées : reprises d'AJEAN
L'amont (AJEAN 0.15) donne à l'IA le contrôle d'un navigateur ; Loki
embarquait déjà un Chromium pour `web_screenshot` sans jamais le piloter.

Contrôle du navigateur (computer_use.go, computer_cdp.go, browser_grid.go,
portés d'AJEAN) : l'IA ouvre une page en CDP, en reçoit les éléments
interactifs NUMÉROTÉS et agit par numéro (browser_open/snapshot/find/click/
type/key/scroll). Aucune vision requise — un petit modèle texte s'en sort.
Avec un projecteur chargé s'ajoutent browser_screenshot (image quadrillée)
et browser_click_xy. Interrupteur dédié (Réglages, `loki computer`,
/api/computer), sous le mode agent : cliquer et taper dans une page sont des
actions réelles, même niveau de confiance que bash.

Adaptation au conteneur : chromePath cherche D'ABORD le Chromium de
Playwright (PLAYWRIGHT_BROWSERS_PATH, /opt/pw-browsers), le seul navigateur
de l'image — sinon la fonctionnalité se déclarait absente là où le
navigateur est présent. LOKI_CHROME force un chemin, LOKI_CU_HEADFUL ouvre
une vraie fenêtre.

Préparation des images (web_upload_orient.go, porté d'AJEAN) : l'orientation
EXIF est cuite dans les pixels — le projecteur l'ignore et voyait les photos
de téléphone couchées — et le grand côté ramené sous 1568 px. Appliquée aux
pièces jointes, à see_image et aux captures.

Dédup des appels d'outils : bash, bash_bg, bash_tail, see_image et les
browser_* ne sont plus court-circuités sur un appel identique. Relancer la
même commande après avoir modifié un fichier est légitime, et un outil qui
porte une image dans un message à part renvoyait « [déjà fait] » SANS
l'image — le modèle tournait en boucle.

Mode code : le builder ne publie plus de lui-même (pas de commit/push/reset/
rebase ni de redémarrage de service sans demande explicite), reprise de la
leçon d'AJEAN 0.15.4 ; l'inspection en lecture seule reste encouragée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:19:37 +00:00

268 lines
8.0 KiB
Go

// web_upload_orient.go — préparation des images avant envoi au modèle :
// redressement de l'orientation EXIF ET redimensionnement des images trop
// grandes.
//
// Orientation : les photos prises au téléphone sont presque toujours
// enregistrées dans l'orientation NATIVE du capteur (souvent paysage), avec un
// tag EXIF Orientation qui dit au visualiseur de les tourner à l'affichage. Les
// navigateurs et apps photo respectent ce tag, donc l'utilisateur voit l'image
// droite ; mais le projecteur multimodal (mmproj) de llama.cpp IGNORE l'EXIF et
// reçoit les pixels bruts. Le modèle voyait alors l'image tournée de 90°.
//
// Redimensionnement : une image de plusieurs milliers de pixels de côté est
// inutilement lourde — le projecteur la redécoupe de toute façon à sa propre
// résolution (quelques centaines de pixels par tuile). L'envoyer en pleine
// définition ne fait que gonfler le base64 transféré et les tokens visuels, sans
// rien apprendre de plus au modèle. On plafonne donc le plus grand côté à
// maxImageDim : c'est ce que font les fournisseurs d'API vision (~1568 px), et
// c'est visuellement sans perte à l'échelle où le modèle regarde. Les PNG
// restent réencodés en PNG (sans perte, utile pour les captures d'écran à
// texte) ; le reste part en JPEG.
//
// On CUIT donc orientation et taille dans les pixels : décoder, redresser,
// redimensionner, réencoder. Repli systématique sur les octets bruts en cas de
// pépin : préparer une image ne doit jamais faire échouer un envoi.
package loki
import (
"bytes"
"encoding/binary"
"image"
"image/draw"
"image/jpeg"
"image/png"
"math"
)
const (
// maxImageDim plafonne le plus grand côté d'une image envoyée au modèle.
maxImageDim = 1568
// imageJPEGQuality : qualité de réencodage JPEG (photos). Assez haut pour
// rester net à l'œil, assez bas pour couper franchement le poids du base64.
imageJPEGQuality = 88
)
// prepareImageForModel prend les octets d'une image et son type MIME, et renvoie
// une version prête pour la vision : orientation EXIF cuite dans les pixels et
// grand côté ramené sous maxImageDim. Si rien n'est à faire (bon format, pas de
// tag, image déjà petite) ou si le décodage échoue (WEBP/BMP hors stdlib, octets
// abîmés), elle renvoie les octets d'origine inchangés — jamais d'échec.
func prepareImageForModel(raw []byte, mime string) ([]byte, string) {
orient := 0
if mime == "image/jpeg" {
orient = exifOrientation(raw)
}
needOrient := orient >= 2 && orient <= 8
src, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return raw, mime // format non décodable ici (webp/bmp…) : laisser tel quel
}
b := src.Bounds()
needResize := b.Dx() > maxImageDim || b.Dy() > maxImageDim
if !needOrient && !needResize {
return raw, mime // rien à changer : préserver les octets d'origine
}
img := src
if needOrient {
img = applyOrientation(img, orient)
}
if img.Bounds().Dx() > maxImageDim || img.Bounds().Dy() > maxImageDim {
img = scaleDownToMax(img, maxImageDim)
}
var buf bytes.Buffer
if mime == "image/png" {
if err := png.Encode(&buf, img); err != nil {
return raw, mime
}
return buf.Bytes(), "image/png"
}
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: imageJPEGQuality}); err != nil {
return raw, mime
}
return buf.Bytes(), "image/jpeg"
}
// toNRGBA renvoie une vue *image.NRGBA de src (sans copie s'il l'est déjà), pour
// un accès rapide aux octets de pixels dans scaleDownToMax.
func toNRGBA(src image.Image) *image.NRGBA {
if n, ok := src.(*image.NRGBA); ok {
return n
}
b := src.Bounds()
n := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
draw.Draw(n, n.Bounds(), src, b.Min, draw.Src)
return n
}
// scaleDownToMax réduit une image pour que son plus grand côté vaille au plus
// maxDim, en conservant le ratio. Moyennage par aire (box filter) : chaque pixel
// de destination est la moyenne des pixels source qu'il recouvre — le meilleur
// filtre simple pour une RÉDUCTION (pas de crénelage ni de halo), sans
// dépendance externe.
func scaleDownToMax(src image.Image, maxDim int) image.Image {
b := src.Bounds()
w, h := b.Dx(), b.Dy()
if w <= maxDim && h <= maxDim {
return src
}
scale := float64(maxDim) / float64(w)
if h > w {
scale = float64(maxDim) / float64(h)
}
nw := int(math.Round(float64(w) * scale))
nh := int(math.Round(float64(h) * scale))
if nw < 1 {
nw = 1
}
if nh < 1 {
nh = 1
}
s := toNRGBA(src)
dst := image.NewNRGBA(image.Rect(0, 0, nw, nh))
for dy := 0; dy < nh; dy++ {
sy0 := dy * h / nh
sy1 := (dy + 1) * h / nh
if sy1 <= sy0 {
sy1 = sy0 + 1
}
for dx := 0; dx < nw; dx++ {
sx0 := dx * w / nw
sx1 := (dx + 1) * w / nw
if sx1 <= sx0 {
sx1 = sx0 + 1
}
var r, g, bl, a, cnt uint64
for sy := sy0; sy < sy1; sy++ {
for sx := sx0; sx < sx1; sx++ {
i := s.PixOffset(s.Rect.Min.X+sx, s.Rect.Min.Y+sy)
r += uint64(s.Pix[i])
g += uint64(s.Pix[i+1])
bl += uint64(s.Pix[i+2])
a += uint64(s.Pix[i+3])
cnt++
}
}
di := dst.PixOffset(dx, dy)
dst.Pix[di] = byte(r / cnt)
dst.Pix[di+1] = byte(g / cnt)
dst.Pix[di+2] = byte(bl / cnt)
dst.Pix[di+3] = byte(a / cnt)
}
}
return dst
}
// exifOrientation renvoie la valeur du tag EXIF Orientation (1..8) d'un JPEG, ou
// 0 si absent/illisible. On lit à la main le segment APP1 pour éviter une
// dépendance : la structure est simple et le tag vit dans l'IFD0.
func exifOrientation(b []byte) int {
// En-tête JPEG : SOI = 0xFFD8.
if len(b) < 4 || b[0] != 0xFF || b[1] != 0xD8 {
return 0
}
pos := 2
for pos+4 <= len(b) {
if b[pos] != 0xFF {
return 0 // désynchronisé
}
marker := b[pos+1]
// SOS (début des données image) ou EOI : plus aucun segment de métadonnées.
if marker == 0xDA || marker == 0xD9 {
return 0
}
segLen := int(binary.BigEndian.Uint16(b[pos+2 : pos+4]))
if segLen < 2 || pos+2+segLen > len(b) {
return 0
}
seg := b[pos+4 : pos+2+segLen]
// APP1 = 0xFFE1, préfixé "Exif\0\0" pour les données EXIF.
if marker == 0xE1 && len(seg) >= 6 && string(seg[:6]) == "Exif\x00\x00" {
return exifOrientationFromTIFF(seg[6:])
}
pos += 2 + segLen
}
return 0
}
// exifOrientationFromTIFF lit l'orientation dans le bloc TIFF d'un segment EXIF.
func exifOrientationFromTIFF(t []byte) int {
if len(t) < 8 {
return 0
}
var bo binary.ByteOrder
switch string(t[:2]) {
case "II":
bo = binary.LittleEndian
case "MM":
bo = binary.BigEndian
default:
return 0
}
if bo.Uint16(t[2:4]) != 0x002A {
return 0
}
ifdOff := int(bo.Uint32(t[4:8]))
if ifdOff < 8 || ifdOff+2 > len(t) {
return 0
}
n := int(bo.Uint16(t[ifdOff : ifdOff+2]))
entry := ifdOff + 2
for i := 0; i < n; i++ {
if entry+12 > len(t) {
return 0
}
tag := bo.Uint16(t[entry : entry+2])
if tag == 0x0112 { // Orientation
// Type SHORT : la valeur tient dans les 2 premiers octets du champ valeur
// (offset entry+8).
return int(bo.Uint16(t[entry+8 : entry+10]))
}
entry += 12
}
return 0
}
// applyOrientation renvoie une nouvelle image où l'orientation EXIF a été cuite
// dans les pixels. Les 8 cas du tag combinent rotations (multiples de 90°) et
// miroirs.
func applyOrientation(src image.Image, orient int) image.Image {
b := src.Bounds()
w, h := b.Dx(), b.Dy()
// Les orientations 5..8 échangent largeur et hauteur (rotation d'un quart).
swap := orient >= 5
dw, dh := w, h
if swap {
dw, dh = h, w
}
dst := image.NewNRGBA(image.Rect(0, 0, dw, dh))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
c := src.At(b.Min.X+x, b.Min.Y+y)
var nx, ny int
switch orient {
case 2: // miroir horizontal
nx, ny = w-1-x, y
case 3: // 180°
nx, ny = w-1-x, h-1-y
case 4: // miroir vertical
nx, ny = x, h-1-y
case 5: // transpose (diagonale principale)
nx, ny = y, x
case 6: // 90° horaire
nx, ny = h-1-y, x
case 7: // transverse (diagonale secondaire)
nx, ny = h-1-y, w-1-x
case 8: // 90° anti-horaire
nx, ny = y, w-1-x
default:
nx, ny = x, y
}
dst.Set(nx, ny, c)
}
}
return dst
}