AI Vision Lab — działanie systemu

Dokumentacja end-to-end scenariusza "River — forma auto (lokalnie + AI)" w pliku pages/ai-vision-lab.html.

System wykrywa kontur formy na zdjęciach river table — działa zarówno dla form sfotografowanych "na prosto" jak i pod skosem / w perspektywie. Pod spodem hybryda: AI (Gemini przez Kie.ai lub Google bezpośrednio) + lokalny snap pikselowy + lokalne fallbacky.


Step-by-step

0. Setup (1 raz, przy ładowaniu strony)

ai-vision-lab.html
├── Turnstile widget renderuje się niewidocznie (site key w stałej)
├── els.engine dropdown czyta z DOM (Auto / Google / Kie.ai)
└── PROMPTS object zawiera 12 wariantów promptów, w tym segmentsPerspective

1. User wgrywa zdjęcie

handleFile(file)
├── FileReader → dataURL
├── new Image() → wczytanie do RAM
├── state.imageElement = img
├── state.imageDataUrl = dataURL
├── state.contourBaseline = null     ← czyszczę poprzedni quad
├── setContourTuneVisible(false)     ← chowam suwak
└── drawCanvas() — pokazuje samo zdjęcie bez overlay

2. User klika "Wykryj formę auto"

runSamLocalAnalysis()  (linia ~2483)
├── buildImagePayload()
│   ├── tworzy <canvas>, skaluje obraz do maxEdge (1024 px dla samLocal)
│   ├── obraca o rotationDeg (z preview)
│   ├── enkoduje JPEG quality 70%
│   └── zwraca { base64, dataUrl, w, h, bytes, rotationDeg }
├── isAiArbiterEnabled() — czy checkbox AI arbiter ✓
│   └── domyślnie TAK dla samLocal (preset z applyTemplate)
└── try { tryAiPerspectiveContour(payload) } catch { fallback lokalny }

3. AI request — dispatcher silników

tryAiPerspectiveContour(payload)
├── turnstileToken = await getTurnstileToken()  ← Cloudflare challenge
├── body = {
│     image: payload.base64,
│     prompt: PROMPTS.segmentsPerspective,
│     model: 'kie-gemini-2.5-pro',
│     temperature: 0, maxTokens: 1400, disableThinking: true,
│     turnstileToken
│   }
└── res = await callAI(body, { engine: selectedEngine() })
callAI(body, { engine }) — linia ~334
├── engine === 'auto' (domyślny):
│   ├── POST /api/gemini.php
│   ├── jeśli 429 → POST /api/kie.php z normalizeKieModel()
│   │   └── (strip prefiksu "kie-", walidacja allowlisty)
│   └── zwraca pierwszą udaną odpowiedź
├── engine === 'google':
│   └── POST /api/gemini.php (Twój klucz GEMINI_API_KEY)
└── engine === 'kie':
    └── POST /api/kie.php (Twój klucz KIE_AI_API_KEY)

4. Backend proxy (PHP)

/api/kie.php
├── nr_api_init(['POST']) + rate limit 30/min
├── walidacja Turnstile (POST do challenges.cloudflare.com/turnstile/v0/siteverify)
│   └── 403 jeśli failed
├── walidacja modelu z allowlisty
├── walidacja image base64 (max 4 MB)
├── budowa OpenAI-compatible payload:
│   {
│     messages: [{
│       role: 'user',
│       content: [
│         { type: 'text', text: prompt },
│         { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,...' } }
│       ]
│     }],
│     temperature, max_tokens, response_format
│   }
├── curl → https://api.kie.ai/v1/chat/completions
├── normalizacja odpowiedzi do kształtu Gemini:
│   {
│     provider: 'kie',
│     candidates: [{ content: { parts: [{ text }] }, finishReason }],
│     usageMetadata,
│     rawResponse
│   }
└── echo JSON

5. Frontend dostaje odpowiedź, parsuje quad

tryAiPerspectiveContour kontynuacja:
├── data = await res.json()
├── text = extractText(data)         ← czyta candidates[0].content.parts[N].text
├── parsed = tryParseJSON(text)      ← { kontur_formy: [[x,y]×4], tryb_perspektywy, uwagi }
├── parsed = normalizePerspectiveFormContour(parsed)
│   ├── orderQuadPoints — sortuje 4 punkty: TL, TR, BR, BL po atan2 wokół centroidu
│   ├── isPerspectiveQuadUsable — sprawdza:
│   │   - 4 unikalne punkty
│   │   - area ≥ 8000 i ≥ 22% bbox area
│   │   - boki się nie przecinają (czy to convex quad)
│   └── jeśli niepoprawny → zwraca pusty quad
└── jeśli pusty quad → return null (caller idzie na fallback lokalny)

6. ⭐ Decyzja kluczowa: axis-aligned vs perspective

if (isQuadAxisAligned(contour, tolerance=35))  ← w skali 0-1000
isQuadAxisAligned(quad)
└── sprawdza 4 warunki (tolerancja ±35 jednostek = ±3.5%):
    ├── tl.x ≈ bl.x   (lewy bok prawie pionowy)
    ├── tr.x ≈ br.x   (prawy bok prawie pionowy)
    ├── tl.y ≈ tr.y   (góra prawie pozioma)
    └── br.y ≈ bl.y   (dół prawie poziomy)

Branch A: forma "na prosto" (axis-aligned)

snapContourToInnerWhiteForm(contour)  ← linia ~2666
├── buduje białą maskę pixel-perfect (threshold min ≥118, lum ≥158, sat ≤78)
├── próba 1: findInnerFormRectFromWhiteRuns
│   └── szuka "pasm" białych pikseli (rzędy/kolumny gdzie >X% białe)
├── próba 2 (fallback): findHorizontalEdge + findVerticalEdge
│   └── walk po luma map, szuka gradient dark→light w okolicy AI quada
├── zwraca: { points: [[x,y]×4 axis-aligned], method, shifts }
└── walidacja przez isPerspectiveQuadUsable

Branch B: forma obrócona

quad surowy od AI bez modyfikacji
console.log('Quad rotated/perspective → AI quad bez lokalnego snapu.')

7. Render + baseline

W runSamLocalAnalysis:
├── state.lastParsed = ai.parsed
├── captureContourBaseline(ai.parsed.kontur_formy, 0)
│   ├── state.contourBaseline = quad.slice()
│   ├── state.contourTunePercent = 0
│   ├── els.contourInset.value = "0"  ← suwak na 0%
│   └── setContourTuneVisible(true)   ← pokazuje pasek
├── renderOutput(text, parsed, finishReason)  ← JSON + key-value w panelu
├── drawCanvas(parsed)
│   ├── rysuje obraz
│   ├── rysuje siatkę alignmentu (jeśli włączona)
│   ├── rysuje quad jako zielony obrys
│   └── nakłada label "Forma · poziom drewna"
├── renderUsage(usage)         ← tokens + koszt $
├── saveHistory(...)           ← localStorage HISTORY_KEY
└── showStatus('success', `AI po skosie znalazło formę (Xms · Y tok · $Z)`)

8. User opcjonalnie tunuje suwakiem

els.contourInset 'input' event → onContourInsetChange()
├── pct = parseFloat(slider.value)  ← np. 3.25
├── newQuad = shrinkQuadTowardCentroid(state.contourBaseline, pct/100)
│   └── każdy róg przesuwa się o pct% w stronę centroidu (zachowuje rotację)
├── state.lastParsed.kontur_formy = newQuad
├── state.lastParsed.kontur_formy_poziom_drewna = newQuad
├── state.lastParsed.kontur_formy_roboczy = newQuad
└── drawCanvas(state.lastParsed)    ← real-time przerysowanie

9. Fallback (gdy AI padło lub quad nieużywalny)

Hierarchia degradacji:

1. tryAiPerspectiveContour → null (quad odrzucony) lub throw (429, parse error, transport)
   ↓
2. detectLocalBrightFormQuad(payload.dataUrl)
   ├── strict + balanced white masks
   ├── connected components → rotated bounding rects (16 wariantów: trim × inset)
   ├── snapQuadToLocalEdges → kandydaci z lokalnym scoringiem
   └── jeśli AI arbiter ON → applyGeminiCandidateArbiter (drugie AI call wybierające najlepszego)
   ↓
3. SAM in-browser (Xenova/slimsam-77-uniform przez Web Worker)
   └── buildSamAutoSeeds + runSamWorker + quadFromSamMask
   ↓
4. runLocalFastFormFallback (ostateczny: lokalna geometria bez AI)
   ↓
5. showStatus('error', 'Nie udało się...') — komunikat dla użytkownika

Mapowanie plików

PlikRola
pages/ai-vision-lab.htmlUI + cała logika JS (3900+ linii)
api/gemini.phpProxy do Google Gemini API + opcjonalnie Kie
api/kie.phpStandalone proxy do Kie.ai (/v1/chat/completions)
api/_common.phpHelpers (Turnstile init, CORS, rate limit, JSON body parsing)
private/config/.envSekrety (KIE_AI_API_KEY, GEMINI_API_KEY, TURNSTILE_SECRET_KEY)
_env.phpLoader env files do PHP getenv()
config.phpKonfiguracja czytająca z env

Kluczowe funkcje JS (ai-vision-lab.html)

FunkcjaLokalizacjaRola
callAI(body, {engine})~334Dispatcher silników (Google / Kie) z auto-fallback przy 429
normalizeKieModel(model)~328Strip prefiksu "kie-", walidacja allowlisty
selectedEngine()~367Czyta wartość dropdowna
tryAiPerspectiveContour(payload)~2458Pełna ścieżka AI → quad → snap/raw
isQuadAxisAligned(quad, tol)~2415Czy quad praktycznie axis-aligned
shrinkQuadTowardCentroid(quad, ratio)~2428Centroid-shrink (suwak inset)
snapContourToInnerWhiteForm(contour)~2666Pixel-snap do białej maski (axis-aligned)
detectLocalBrightFormQuad(dataUrl)~1735Lokalny detektor kandydatów (fallback bez AI)
applyGeminiCandidateArbiter(payload, detected)~2293AI arbiter wybiera kandydata z listy
runSamLocalAnalysis()~2483Główny orchestrator scenariusza "Forma auto"
captureContourBaseline(quad, defaultPct)~2998Zapisuje baseline dla suwaka
onContourInsetChange()~3022Handler suwaka — przelicza quad i odświeża canvas

Wymagane sekrety w .env

KIE_AI_API_KEY=<klucz z panelu kie.ai>
KIE_AI_BASE_URL=https://api.kie.ai
GEMINI_API_KEY=<klucz z aistudio.google.com>
TURNSTILE_SECRET_KEY=<secret z Cloudflare Turnstile>

Po zmianie .env zrestartuj PHP-FPM, żeby getenv() zwracało świeże wartości.

Stałe pułapki

  1. Klucz Kie spalony — jeśli wkleiłeś go w czat / commit / log, rotuj go natychmiast w panelu Kie.ai
  2. Turnstile token jednorazowygetTurnstileToken() musi być wywołane przed każdym requestem
  3. Tolerance axis-aligned = 35 — gdyby AI zwracał formy "prawie na prosto" ale lekko skośne (np. 4% odchylenie), nie wejdą w lokalny snap. Zwiększ tolerance do 50 jeśli zauważysz że dobre formy idą Branch B niepotrzebnie.
  4. snapContourToInnerWhiteForm używa state.imageElement (oryginalny img), nie payload-canvas. Jeśli kiedyś dodasz obrót preview, snap musi być świadom rotacji.
  5. Suwak inset baseline — kapsuluje się przy każdym Run. Jeśli zrobisz Run dwa razy z rzędu, drugi reset zerwie pierwszą korektę użytkownika.
  6. JPEG quality 70% dla samLocal — kompresja może zniszczyć cienką białą krawędź formy. Jeśli zauważysz że snap miss-uje, podnieś quality do 85% w preset scenariusza (linia ~864).
  7. maxEdge 1024 dla samLocal — większe = dokładniejsze, ale wolniejsze i większe tokeny. Dla form ≥ 4K kamery to wystarcza; dla makro / detali zwiększ do 1600.

Diagnostyka — co czytać w DevTools Console

[Vision Lab] AI engine=kie status=200                                ← połączenie Kie OK
[Vision Lab] AI engine=auto/google status=429                         ← Google quota wyczerpana
[Vision Lab] Google 429, próba Kie.ai. Primary body: ...             ← auto-fallback
[Vision Lab] Axis-aligned quad → snap do wewnętrznej krawędzi OK.    ← Branch A (forma na prosto)
[Vision Lab] Quad rotated/perspective → AI quad bez lokalnego snapu. ← Branch B (forma obrócona)
[Vision Lab] AI perspective: quad odrzucony przez isPerspectiveQuadUsable.  ← AI dał zły quad
[Vision Lab] AI perspective błąd: ...                                ← throw → fallback lokalny

Mapa scenariuszy (do diagnostyki / porównań)

Scenariusz w dropdownIDBackendUse case
River — forma auto (lokalnie + AI)samLocalHybryda AI perspective + axis-aligned snapGłówny workflow
River — kontur formysegmentsGemini segmentation + inner-white snapTylko formy na wprost (axis-aligned)
River — kontur formy po skosiesegmentsPerspectiveGemini segmentation, surowy quadTylko formy obrócone (perspektywa)
River — forma + rzeka (legacy)riverStary prompt, do wycofania
Forma biała — 4 rogiwhiteFormLokalna detekcja białej maski (bez AI)Backup gdy AI niedostępny
Detekcja elementów (dokładna)objectsGemini object detection z labelamiAnaliza całej sceny
Wykrywanie ubytków drewnadefectsGemini detection sęków/pęknięćPo wycięciu drewna
Opis sceny (diagnostyka)sceneKrótki opis zdjęciaSanity check że AI w ogóle widzi obraz
Analiza kadruframeOcena czy zdjęcie nadaje się do pracyPre-check przed Run
Rozpoznawanie formyformStary prompt formy, do wycofania
Refine — popraw 4 rogirefineUser klika rogi, AI dociąga do krawędziManual fallback
Własny promptcustomDowolny prompt z textareaTesty / development

samLocal (Forma auto) jest dziś jedynym potrzebnym scenariuszem dla użytkownika końcowego. Pozostałe to narzędzia diagnostyczne.