FloorVisualizer/internal/handler/floor_handler.go

629 lines
26 KiB
Go
Raw Normal View History

package handler
import (
"context"
"database/sql"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"floorvisualizer/internal/openrouter"
"floorvisualizer/internal/repository"
)
// ── Floor options ──────────────────────────────────────────
type FloorOption struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Material string `json:"material"`
ColorTone string `json:"color_tone"`
Finish string `json:"finish"`
SizeLabel string `json:"size_label"`
WidthIn float64 `json:"width_in"`
LengthIn float64 `json:"length_in"`
ImageURL string `json:"image_url"`
SKU string `json:"sku"`
Description string `json:"description"`
Variants []FloorOption `json:"variants,omitempty"`
}
type PatternOption struct {
Code int `json:"code"`
Name string `json:"name"`
Pattern string `json:"pattern"`
Category string `json:"category"`
}
type RoomOption struct {
Code int `json:"code"`
Name string `json:"name"`
}
var woodPatterns = []PatternOption{
{Code: 1, Name: "直铺工字拼Straight Lay", Category: "wood", Pattern: "planks running from bottom-left toward top-right of the image, joints staggered randomly, at least 6 inch offset between rows"},
{Code: 2, Name: "横铺 Horizontal Lay", Category: "wood", Pattern: "planks running horizontally left to right, parallel to the bottom edge of the image, joints staggered randomly, at least 6 inch offset"},
{Code: 3, Name: "人字拼 Herringbone", Category: "wood", Pattern: "planks at 90° forming continuous zigzag V-lines from bottom-left to top-right of the image"},
{Code: 4, Name: "鱼骨拼 Chevron", Category: "wood", Pattern: "planks with 45° mitered ends forming continuous V-points aligned from bottom-left to top-right of the image"},
}
var tilePatterns = []PatternOption{
{Code: 5, Name: "直铺网格对缝Straight Grid", Category: "tile", Pattern: "tiles aligned in rows (parallel to bottom edge) and columns (perpendicular to bottom edge), all grout lines continuous, no offset"},
{Code: 6, Name: "工字铺1/2错缝Running Bond", Category: "tile", Pattern: "rows parallel to bottom edge of image, each row offset 50% from previous, horizontal grout lines continuous, vertical grout lines staggered"},
{Code: 7, Name: "三七错铺 1/3 Offset", Category: "tile", Pattern: "rows parallel to bottom edge of image, each row offset exactly 33% of tile length, every third row aligns"},
{Code: 8, Name: "六边形 Hexagonal", Category: "tile", Pattern: "six-sided tiles interlocked in honeycomb mesh, flat edges horizontal (parallel to bottom edge of image), grout follows hexagonal edges"},
{Code: 9, Name: "斜铺60度对角Diagonal", Category: "tile", Pattern: "tiles rotated 60° to the image frame, grout lines run diagonally at 60° and 150° to the bottom edge, no lines parallel to image edges"},
}
var roomOptions = []RoomOption{
{Code: 0, Name: "自动识别"},
{Code: 1, Name: "客厅"},
{Code: 2, Name: "卧室"},
{Code: 3, Name: "厨房"},
{Code: 4, Name: "餐厅"},
{Code: 5, Name: "浴室"},
{Code: 6, Name: "书房"},
{Code: 7, Name: "走廊"},
}
func findPatternByCode(code int) *PatternOption {
for _, p := range append(woodPatterns, tilePatterns...) {
if p.Code == code {
return &p
}
}
return nil
}
func findRoomByCode(code int) *RoomOption {
if code == 0 {
return nil
}
for _, r := range roomOptions {
if r.Code == code {
return &r
}
}
return nil
}
// loadFloorOptions loads all products, deduplicated by brand+style_name. Variants
// (different sizes of the same style) are attached to the representative entry.
func loadFloorOptions(db *sql.DB) ([]FloorOption, error) {
// Use raw query without DISTINCT ON so all size variants are returned
rows, err := db.Query(`SELECT brand, group_name, sku, series_name, style_name,
category, material, color_tone, finish, price_per_sqft, price_tier, price_source,
is_official_price, main_image_url, room_image_url, source_url, description, status,
COALESCE(coverage_sqft_per_box,0), COALESCE(wood_species,''),
size_label, COALESCE(width_in,0), COALESCE(length_in,0)
FROM products ORDER BY brand, style_name, id LIMIT 3000`)
if err != nil {
return nil, err
}
defer rows.Close()
prods, err := repository.ScanProducts(rows)
if err != nil {
return nil, err
}
type key struct{ brand, style string }
seen := map[key]int{} // key → index in out slice
var out []FloorOption
for _, p := range prods {
k := key{p.Brand, p.StyleName}
if idx, ok := seen[k]; ok {
// Add as variant to the representative entry
out[idx].Variants = append(out[idx].Variants, FloorOption{
SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
SKU: p.SKU,
})
} else {
seen[k] = len(out)
out = append(out, FloorOption{
ID: p.SKU, Name: p.StyleName, Category: p.Category,
Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish,
SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
ImageURL: p.MainImageURL, SKU: p.SKU, Description: p.Description,
})
}
}
return out, nil
}
func isTileProduct(p FloorOption) bool {
m := strings.ToLower(p.Material)
return strings.Contains(m, "porcelain") || strings.Contains(m, "ceramic") ||
strings.Contains(m, "stone look") || m == "glass"
}
func isVinylProduct(p FloorOption) bool {
m := strings.ToLower(p.Material)
return m == "luxury vinyl" || strings.Contains(m, "rigid core") || m == "vinyl"
}
func isLaminateProduct(p FloorOption) bool {
return strings.EqualFold(p.Material, "laminate")
}
func isWoodProduct(p FloorOption) bool {
return !isTileProduct(p) && !isVinylProduct(p) && !isLaminateProduct(p)
}
func findFloorBySKU(db *sql.DB, sku string) *FloorOption {
p, err := repository.GetProductBySKU(db, sku)
if err != nil {
return nil
}
f := &FloorOption{
ID: p.SKU, Name: p.StyleName, Category: p.Category,
Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish,
SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
ImageURL: p.MainImageURL, SKU: p.SKU, Description: p.Description,
}
// Fetch size variants (same style, different dimensions)
variants, _ := repository.GetVariantsByStyle(db, sku)
for _, vp := range variants {
f.Variants = append(f.Variants, FloorOption{
SizeLabel: vp.SizeLabel, WidthIn: vp.WidthIn, LengthIn: vp.LengthIn,
SKU: vp.SKU,
})
}
// Canonical reference image = smallest size variant (by width)
// All sizes of the same style use the same reference image to prevent color drift
canonicalSKU, canonicalImage, canonicalWidth, canonicalLength := p.SKU, p.MainImageURL, f.WidthIn, f.LengthIn
rows, _ := db.Query(`SELECT sku, main_image_url, COALESCE(width_in,0), COALESCE(length_in,0) FROM products
WHERE style_name = $1 AND brand = $2
ORDER BY COALESCE(width_in,999), COALESCE(length_in,999) LIMIT 1`,
p.StyleName, p.Brand)
if rows != nil {
defer rows.Close()
if rows.Next() {
rows.Scan(&canonicalSKU, &canonicalImage, &canonicalWidth, &canonicalLength)
}
}
f.ImageURL = canonicalImage
// Build dynamic size instruction if selected size differs from reference image size
if (canonicalWidth > 0 && canonicalLength > 0) && (f.WidthIn > 0 && f.LengthIn > 0) {
if canonicalWidth != f.WidthIn || canonicalLength != f.LengthIn {
f.Description = fmt.Sprintf(
`SIZE MAPPING: The reference image shows %.1f"×%.1f" planks. Generate %.1f"×%.1f" planks — same exact material, only scale the board size proportionally. | %s`,
canonicalWidth, canonicalLength, f.WidthIn, f.LengthIn, f.Description,
)
}
}
return f
}
// ── Public accessors for worker ────────────────────────────
func FindFloorBySKUPublic(db *sql.DB, sku string) *FloorOption { return findFloorBySKU(db, sku) }
func FindPatternByCodePublic(code string) *PatternOption {
c := 0
fmt.Sscanf(code, "%d", &c)
return findPatternByCode(c)
}
func FindRoomByCodePublic(code string) *RoomOption {
c := 0
fmt.Sscanf(code, "%d", &c)
return findRoomByCode(c)
}
func BuildMaskPromptPublic(room *RoomOption) string { return buildMaskPrompt(room) }
func BuildFloorPromptPublic(f FloorOption, p PatternOption, room *RoomOption) string {
return buildFloorPrompt(f, p, room)
}
// BuildCombinedFloorPrompt builds a single prompt that identifies the floor area AND replaces it.
// No separate mask generation step — the AI does both in one pass.
func BuildCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomOption) string {
floorPrompt := buildFloorPrompt(f, p, room)
roomCtx := ""
if room != nil && room.Code != 0 {
roomCtx = fmt.Sprintf(" This is a %s.", room.Name)
}
return fmt.Sprintf(
`FIRST identify the floor: Look at this room photo.%s
The floor is the flat horizontal walking surface NOT walls, NOT baseboards, NOT furniture, NOT stairs.
You will replace ONLY the floor area. Every other pixel must remain bit-identical to the input photo.
%s
Return ONLY the completed image. No text.`,
roomCtx,
floorPrompt,
)
}
// buildPatternConstraint returns installation rules specific to each pattern code.
func buildPatternConstraint(code int) string {
switch code {
case 1:
return `PATTERN RULES — Straight Lay (wood): Planks run from the bottom-left of the image toward the top-right, at roughly 45° to the image frame. Long edges of each plank point toward the upper-right corner. Joints staggered 6" minimum — random pattern. 1/8" expansion gap.`
case 2:
return `PATTERN RULES — Horizontal Lay (wood): Planks run horizontally — left to right, parallel to the bottom edge of the image. Long edges of each plank are horizontal across the photo. Joints staggered 6" minimum — random pattern. 1/8" expansion gap.`
case 3:
return `PATTERN RULES — Herringbone (wood): Planks at 90° forming continuous zigzag. Each plank end meets neighbor at 90°. V-lines run from bottom-left to top-right of the image. Joints tight — continuous unbroken zigzag.`
case 4:
return `PATTERN RULES — Chevron (wood): Planks with 45° mitered ends form continuous V. V-points aligned in a straight line running bottom-left to top-right across the image. Joint lines are straight and continuous. NOT herringbone — mitered joints, not staggered.`
case 5:
return `PATTERN RULES — Straight Grid (tile): Tiles aligned in rows and columns. Grout lines run horizontally (parallel to bottom edge of image) and vertically (perpendicular to bottom edge). NO offset between rows. Grout ~1/8".`
case 6:
return `PATTERN RULES — Running Bond (tile): Each row offset 50% from previous — classic brickwork. Grout lines parallel to bottom edge of image are continuous; grout lines perpendicular to bottom edge are staggered. Grout ~1/8".`
case 7:
return `PATTERN RULES — 1/3 Offset (tile): Each row offset exactly 33% of tile length. Every third row aligns. Rows run parallel to bottom edge of image. Grout ~1/8". NOT 50% bond — offset is exactly 1/3.`
case 8:
return `PATTERN RULES — Hexagonal (tile): Six-sided tiles interlocked in honeycomb mesh. Each tile touches 6 neighbors. Flat edges of hexagons are horizontal (parallel to bottom edge of image). Grout follows hexagonal edges — network pattern, not grid.`
case 9:
return `PATTERN RULES — Diagonal (tile): Tiles rotated 60° relative to the image frame — all tiles at a steep diagonal, diamond orientation. Grout lines run diagonally across the photo at 60° and 150° to the bottom edge. None parallel or perpendicular to image edges. Triangle cuts at all walls. Grout ~1/8".`
default:
return ""
}
}
// ── Floor Generate Handler (Redis queue) ──────────────────
func SetQueue(enqueue func(ctx context.Context, jobID string, payload map[string]string) error) {
generateQueue = enqueue
}
var generateQueue func(ctx context.Context, jobID string, payload map[string]string) error
var queryStatus func(ctx context.Context, jobID string) (*JobStatusResp, error)
func SetStatusQuery(fn func(ctx context.Context, jobID string) (*JobStatusResp, error)) {
queryStatus = fn
}
type JobStatusResp struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Step string `json:"step"`
Message string `json:"message"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
func FloorGenerate(client *openrouter.Client, db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErr(w, 405, "Method not allowed")
return
}
if err := r.ParseMultipartForm(20 << 20); err != nil {
writeErr(w, 400, err.Error())
return
}
file, _, err := r.FormFile("image")
if err != nil {
writeErr(w, 400, "No image")
return
}
defer file.Close()
floor := findFloorBySKU(db, r.FormValue("floor_id"))
patternCode := 0
fmt.Sscanf(r.FormValue("pattern_code"), "%d", &patternCode)
pattern := findPatternByCode(patternCode)
if floor == nil || pattern == nil {
writeErr(w, 400, "Invalid options")
return
}
os.MkdirAll("uploads", 0755)
jobID := fmt.Sprintf("%d", time.Now().UnixNano())
inputPath := filepath.Join("uploads", fmt.Sprintf("input_%s.png", jobID))
dst, err := os.Create(inputPath)
if err != nil {
writeErr(w, 500, "Failed to save image")
return
}
if _, err := io.Copy(dst, file); err != nil {
dst.Close()
writeErr(w, 500, "Failed to write image")
return
}
dst.Close()
os.MkdirAll("outputs", 0755)
if generateQueue == nil {
writeErr(w, 500, "Queue not initialized")
return
}
// Store job payload then enqueue
payload := map[string]string{
"input_path": inputPath,
"floor_id": r.FormValue("floor_id"),
"pattern_code": r.FormValue("pattern_code"),
"room_code": r.FormValue("room_code"),
}
if err := generateQueue(r.Context(), jobID, payload); err != nil {
writeErr(w, 500, "Failed to enqueue job")
return
}
_ = payload // payload stored inside generateQueue wrapper
writeJSON(w, 200, map[string]string{"job_id": jobID})
}
}
// ── Progress ───────────────────────────────────────────────
// ── Floor Status Query ─────────────────────────────────
func FloorStatus(w http.ResponseWriter, r *http.Request) {
jobID := r.URL.Query().Get("job_id")
if jobID == "" {
writeErr(w, 400, "job_id is required")
return
}
if queryStatus == nil {
writeErr(w, 500, "status query not initialized")
return
}
js, err := queryStatus(r.Context(), jobID)
if err != nil {
writeJSON(w, 404, map[string]string{"error": "job not found"})
return
}
writeJSON(w, 200, js)
}
// ── Options ────────────────────────────────────────────────
func FloorOptions(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
all, _ := loadFloorOptions(db)
var allWood, allTile, vinylFloors []FloorOption
for _, f := range all {
switch {
case isVinylProduct(f):
vinylFloors = append(vinylFloors, f)
case isTileProduct(f) || isLaminateProduct(f):
allTile = append(allTile, f)
default:
allWood = append(allWood, f)
}
}
writeJSON(w, 200, map[string]any{
"wood_floors": allWood,
"wood_patterns": woodPatterns,
"tile_floors": allTile,
"tile_patterns": tilePatterns,
"vinyl_floors": vinylFloors,
"rooms": roomOptions,
})
}
}
// ── Prompts ────────────────────────────────────────────────
func buildMaskPrompt(room *RoomOption) string {
base := `You are a precise floor segmentation tool. Create a pure black-and-white mask for this room photo.
STRICT DEFINITIONS:
- WHITE (#FFFFFF): ONLY the flat, horizontal walking surface (the floor). Include the entire visible floor area up to the exact edge where it meets walls, baseboards, cabinets, or stairs.
- BLACK (#000000): EVERYTHING that is not flat horizontal floor walls, baseboards/skirting boards, stairs/stair risers (they are NOT flat floor), furniture and furniture legs, doors and door frames, windows, cabinets, appliances, rugs/carpets, ceiling, decorations, people, pets.
EDGE RULES:
- At the wall-floor boundary: cut exactly along the junction. The baseboard/skirting board is BLACK, the floor is WHITE.
- Furniture legs resting on the floor: the legs are BLACK. The floor visible BETWEEN and AROUND legs is WHITE.
- Stairs: each step's horizontal tread AND vertical riser are BLACK (stairs are not a continuous flat plane).
- Rugs/carpets on the floor: BLACK (they are not the permanent floor).
SELF-CHECK before output:
- Did you include any wall area? fix it.
- Did you miss any floor area between furniture legs? fill it WHITE.
- Did you paint any stair surface WHITE? make it BLACK.
- Are all edges sharp and precise at boundaries? verify.
Output ONLY the mask image. No text, no explanation.`
if room != nil && room.Code != 0 {
base += fmt.Sprintf("\nContext: This is a %s.", room.Name)
}
return base
}
func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption) string {
var roomCtx string
if room != nil && room.Code != 0 {
roomCtx = fmt.Sprintf("\nRoom context: This is a %s.", room.Name)
}
matType := "wood plank"
if isTileProduct(floor) || isLaminateProduct(floor) {
matType = "tile"
}
materialDesc := fmt.Sprintf("%s material, %s tone, %s finish", floor.Material, floor.ColorTone, floor.Finish)
if floor.SizeLabel != "" {
materialDesc += fmt.Sprintf(", %s size", floor.SizeLabel)
}
// Include product description if available for richer visual detail
var descriptionText string
if floor.Description != "" {
descriptionText = fmt.Sprintf("\nVisual appearance: %s", floor.Description)
}
productLock := ""
if floor.ImageURL != "" {
productLock = fmt.Sprintf(
`The reference image is the EXACT product material not style inspiration, not a mood board.
LOCK THESE PROPERTIES from the reference image: base color, color temperature, grain/vein pattern,
knot density, surface texture, gloss level, material character.
FORBIDDEN: color shift, different wood species or stone type, new grain pattern, different stain,
gloss change, "luxury upgrade" or aesthetic reinterpretation.`,
)
}
// Size instruction: only change board proportion, never the material itself
sizeInstruction := ""
if len(floor.Variants) > 0 {
var variantList []string
for _, v := range floor.Variants {
marker := ""
if v.SKU == floor.SKU {
marker = " ← SELECTED"
}
if v.SizeLabel != "" {
variantList = append(variantList, v.SizeLabel+marker)
} else if v.WidthIn > 0 && v.LengthIn > 0 {
variantList = append(variantList, fmt.Sprintf(`%.1f"×%.1f"`, v.WidthIn, v.LengthIn)+marker)
}
}
if len(variantList) > 0 {
sizeInstruction = fmt.Sprintf(
"\nSIZE: This product comes in multiple sizes: %s.\nONLY change board width and seam spacing. The material texture, color, and grain are from the reference image — identical in every size. Wider boards use the SAME texture source, just with fewer seams. Do NOT reinterpret the wood to look \"more premium\" for wider planks.",
strings.Join(variantList, ", "),
)
}
} else if floor.SizeLabel != "" {
sizeInstruction = fmt.Sprintf(
"\nSIZE: Each piece is %s. Only adjust board proportion and seam spacing. The material texture is from the reference image — do not change it.",
floor.SizeLabel,
)
} else if floor.WidthIn > 0 && floor.LengthIn > 0 {
sizeInstruction = fmt.Sprintf(
`\nSIZE: Each piece is %.1f" × %.1f". Only adjust board proportion and seam spacing. The material texture is from the reference image — do not change it.`,
floor.WidthIn, floor.LengthIn,
)
}
patternInstruction := buildPatternConstraint(pattern.Code)
return fmt.Sprintf(
`You are a flooring product visualization renderer for a commercial catalog. Your sole function is to replace the floor surface in interior photos with a specific flooring SKU. This is product-accurate catalog photography NOT interior design or artistic interpretation.
STRICT OUTPUT RULES:
1. Respond with ONLY the completed PNG image. No text, no explanation, no JSON wrapper.
2. The output must have the exact same pixel dimensions as the input image.
Do not crop, pad, upscale, downscale, rotate, or alter the image geometry in any way.
3. Do not apply global filtering, sharpening, blurring, tone-mapping, contrast adjustment,
or style transfer. The only permitted change is the floor surface.
FLOOR SURFACE DEFINITION:
- "Floor" the flat horizontal walking surface. The visible ground plane inside the room.
DO NOT bleed onto: walls, baseboards/skirting boards, stairs, stair risers, furniture,
chair legs, table legs, cabinet bases, doors, door frames, window glass or frames,
trim, pipes, conduit, rugs, carpets, ceiling, appliances, decorations.
- Stairs are NOT floor each tread and riser must remain completely unchanged.
- If a surface is not the flat horizontal floor, PRESERVE IT EXACTLY.
FLOOR REPLACEMENT RULES:
4. Apply the new floor ONLY to the floor surface. Every pixel outside must be bit-identical to input.
5. Preserve the original room lighting, shadows, and highlights ON the new floor.
6. ADD realistic contact shadows: chair wheels, table legs, cabinet bases MUST cast tight dark shadows
directly at the contact point sharpest there, softening outward. This shows physical weight.
7. Maintain clean but physically installed-looking floor edges not a Photoshop cutout.
8. The new floor must look physically installed and photographed not rendered, not composited.
FIDELITY RULES:
9. The output must be a photorealistic edit of the input photograph.
Do not generate new content, synthesise areas, invent objects, or alter composition.
10. Preserve camera sensor noise, natural vignette, and original white balance on all non-floor surfaces.
AMBIGUITY RULE:
11. If a pixel could be floor OR furniture/wall/baseboard preserve the original unchanged.
Precision over completeness.
FLOOR SPECIFICATION:
- Product: %s (%s %s)
- Pattern: %s %s
The pattern changes layout only. The material comes from the reference image identical regardless of pattern.
%s
- Material details: %s%s%s%s
%s
TEXTURE FIDELITY:
- Every plank/tile must have UNIQUE grain, vein, or tonal variation no visible texture repetition.
- Natural imperfections: minor scratches, micro-wear near chair wheels, colour variance between pieces.
- Seams and joints: subtle dust in gaps, micro-shadow at edges, slight edge wear.
- Polished surfaces: reflections UNEVEN and angle-dependent not uniform glossy sheen.
- Do NOT invent sunbeams, light rays, or window reflections not in the original.
NEGATIVE CONSTRAINTS the floor must NOT contain:
CGI look, 3D render, plastic texture, repeating patterns, uniform gloss, invented lights,
HDR tone-mapping, "luxury showroom" aesthetic, sterile clean seams, floating furniture,
color shift, different wood species, new grain, different stain, aesthetic reinterpretation.
REQUIREMENTS SUMMARY:
Only the flat horizontal floor changes all other pixels preserved bit-identical.
Contact shadows under all furniture sharp at contact, softening outward.
Original lighting and shadows match on new floor.
Output geometry identical to input same dimensions, no cropping.
Scene composition unchanged no new objects, no synthesised content.
Material color, grain, gloss, species match the reference image exactly.
Return ONLY the completed image. No text.`,
floor.Name, matType, materialDesc,
pattern.Name, pattern.Pattern, patternInstruction,
materialDesc,
roomCtx, descriptionText, sizeInstruction, productLock,
)
}
// ── Content Check ─────────────────────────────────────────
func validateContent(client *openrouter.Client, ctx context.Context, imagePath string) (bool, int) {
imgData, err := os.ReadFile(imagePath)
if err != nil {
return false, 0
}
dataURI := "data:image/png;base64," + b64enc(imgData)
resp, err := client.Chat(ctx, openrouter.ChatRequest{
Model: "google/gemini-2.5-flash",
Messages: []openrouter.Message{{Role: "user", Content: []openrouter.ContentPart{
{Type: "image_url", ImageURL: &openrouter.ImageURL{URL: dataURI}},
{Type: "text", Text: `Score this image 1-10 for indoor floor replacement suitability.
8-10: Clear indoor room, floor visible. 5-7: Indoor but poor angle. 1-4: Outdoor/pets/objects/no floor.
Return ONLY a number (1-10).`},
}}},
})
if err != nil || len(resp.Choices) == 0 {
return true, 0
}
score := parseScore(resp.Choices[0].Message.Content)
return score >= 6, score
}
func parseScore(raw string) int {
for _, s := range []string{"10", "9", "8", "7", "6", "5", "4", "3", "2", "1"} {
if len(raw) >= len(s) && raw[:len(s)] == s {
v := 0
fmt.Sscanf(s, "%d", &v)
return v
}
}
return 0
}
func b64enc(data []byte) string {
const tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
var b strings.Builder
for i := 0; i < len(data); i += 3 {
b0, b1, b2 := data[i], byte(0), byte(0)
if i+1 < len(data) {
b1 = data[i+1]
}
if i+2 < len(data) {
b2 = data[i+2]
}
b.WriteByte(tbl[b0>>2])
b.WriteByte(tbl[((b0&3)<<4)|(b1>>4)])
if i+1 < len(data) {
b.WriteByte(tbl[((b1&15)<<2)|(b2>>6)])
} else {
b.WriteByte('=')
}
if i+2 < len(data) {
b.WriteByte(tbl[b2&63])
} else {
b.WriteByte('=')
}
}
return b.String()
}