Flutter e OpenAi: un generatore di immagini AI

Qualche giorno fa il mio amico Marco Lancellotti ha iniziato a pubblicare su YouTube alcuni video per mostrare come utilizzare le API di OpenAi per creare un generatore di immagini in PHP/Laravel e così ho provato a fare qualcosa di simile in Flutter: ne è venuto fuori il progetto che vi presento in questo articolo.


Che cosa è OpenAi

OpenAi è un'azienda creata da Elon Musk con l'intento di utilizzare l'intelligenza artificiale per analizzare e comprendere il testo scritto dagli umani, tramite complessi algoritmi di machine learning. OpenAi è oggi attiva in vari settori oltre alla comprensione del testo: è perfettamente in grado di scrivere notizie false con uno stile che sembra in tutto e per tutto quello di uno scrittore umano e allo stesso modo è capace di produrre immagini realistiche a partire da una semplice descrizione testuale.

In questo progetto utilizzeremo proprio la capacità di produrre immagini. Per farlo, occorre che l'utente possa digitare una descrizione dell'immagine desiderata e che sia possibile poi interrogare le API di OpenAi per ottenere il risultato desiderato.

Il progetto base 

Creiamo un nuovo progetto e sostituiamo il codice standard nel file main.dart con questo.


import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      debugShowCheckedModeBanner: false,
      home: const OpenAiImage(),
    );
  }
}


Il widget OpenAiImage

Creiamo ora il nostro widget stateful principale OpenAiImage:



class OpenAiImage extends StatefulWidget {
  const OpenAiImage({super.key});

  @override
  State<OpenAiImage> createState() => _OpenAiImageState();
}

class _OpenAiImageState extends State<OpenAiImage> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Flutter OpenAi test"),
      ),
      body: ...... ,
    );
  }

e inseriamo nel body una casella di testo ed un pulsante, utilizzando una layout semplice a colonna:


      body: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              TextField(
                decoration: const InputDecoration(
                  labelText: 'put description here',
                  contentPadding: EdgeInsets.all(20),
                ),
              ),
              ElevatedButton(
                  onPressed: () {},
                  child: const Text("Genera")),
            ],
          ),


per ottenere un risultato simile a questo:



Per poter accedere al testo inserito dall'utente, dobbiamo introdurre un TextEditingController(). Quindi modifichiamo il codice aggiungendo queste righe (il metodo dispose() è necessario per rilasciare l'oggetto quando non occorre più):


class _OpenAiImageState extends State<OpenAiImage> {
  final textController = TextEditingController();

  @override
  void dispose() {
    textController.dispose();
    super.dispose();
  }

....

              TextField(
                controller: textController,
                decoration: ..... ,
              ),


Le API di OpenAi

Per utilizzare le API rese disponibile da OpenAi occorre registrarsi su questo sito (la registrazione è gratuita) selezionare dal menu a tendina associato al profilo la voce "View API keys" e generare una chiave (fate attenzione, la chiave viene mostrata una sola volta e poi non più, quindi prendete nota). Come accennavo, la registrazione è gratuita, ma l'utilizzo della API è a pagamento: fortunatamente per noi, c'è un credito bonus iniziale di 18 dollari con cui fare un po' di prove, terminato il quale, occorre attivare un piano a pagamento.

Ottenuta la chiave, creiamo un file nel nostro progetto in cui memorizzarla (e ricordiamo di inserire questo file tra quelli da non sincronizzare con Git, per evitare di esporre la nostra chiave nella repo che pubblicheremo). Nel mio caso ho creato un file openai_key.dart con questa semplice riga: 


const OPENAI_KEY = "sk-gP************G8s";

e aggiungiamo l'import di questo file in testa al file main.dart:


import './openai_key.dart';

L'interrogazione delle API

Gli esempi riportati dalla documentazione online di OpenAi, mostrano che per l'utilizzo è necessario  effettuare una richiesta HTTP passando i parametri di interrogazione con il metodo POST. Per questo, occorre innanzitutto aggiungere al nostro progetto un package che ci permette di interagire con un web service. Da terminale, digitiamo il seguente comando:


 flutter pub add http


per aggiungere la dipendenza http nel file pubspec.yaml 


dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  http: ^0.13.5

e aggiungiamo l'import di questa libreria nel file main.dart:


import 'package:http/http.dart';
import 'package:http/http.dart' as http;


aggiungiamo due righe (la seconda con un alias) per semplificare la scrittura del codice quando la utilizzeremo. Importata la libreria, possiamo aggiungere questa funzione al progetto:


  // use OpenAi API to get image
  Future<Response> getImage(String description) async {
    var endPoint = Uri.https('api.openai.com', '/v1/images/generations');

    Map<String, String> headers = {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer $OPENAI_KEY'
    };

    final body = json.encode({
      'prompt': description,
      'n': 1,
      'size': '1024x1024',
    });

    return http.post(endPoint, headers: headers, body: body);
  }


che accetta in ingresso una descrizione ed interroga l'API (il cui endpoint è dichiarato nelle prime righe) passando nel body i parametri richiesti dalle specifiche (prompt, con la richiesta testuale, n, con il numero di immagini da generare e size con le dimensioni desiderate che nel nostro caso abbiamo fissato a 1024x1024, ma volendo sarebbe possibile avere 256x256 o 512x512 ) in formato json (attenzione all'utilizzo della funzione json.encode che richiede anche l'aggiunta di una nuova libreria da importare import 'dart:convert';). La chiamata richiede che nell'header sia dichiarato il formato e la chiave personale (che noi abbiamo inserito nella variabile globale OPENAI_KEY ). La funzione lavora in modo asincrono (per questo motivo è indicata come  async ) e restituisce un Future .

Il risultato è un oggetto json di questo tipo:


Gestiamo il risultato

Aggiungiamo ora la chiamata di questa funzione al nostro pulsante, dopo aver introdotto una nuova variabile di stato in cui memorizzare l'url dell'immagine che otterremo dall'API:


  String result = "";



                  onPressed: () {
                    getImage(textController.value.toString()).then((value) {
                      result = json.decode(value.body)['data'][0]['url'];
                    }).whenComplete(() {
                      setState(() {});
                    });
                  },
                  child: const Text("Genera")),

come argomento passeremo il value.toString() ottenuto dal controller associato al TextField e nel ramo .then() della chiamata asincrona assegniamo il risultato. Ricordiamoci che questo ramo verrà eseguito quando la chiamata è andata a buon fine e la risposta è pronta (per valorizzare result, teniamo conto che la risposta ci viene fornita in formato json e che presenta la struttura vista sopra). Quando la chiamata è stata completata, aggiorniamo lo stato del nostro widget.

Esponiamo il risultato come immagine

Per esporre l'immagine di cui abbiamo ottenuto l'URL dall'interrogazione dell'API, possiamo creare una semplice card (ho copiato e modificato quella che ho ho utilizzato in questo mio precedente progetto: Utilizzo delle immagini in Flutter ):


  // ImageCard
  Widget imageCard(imageUrl) {
    return Padding(
        padding: const EdgeInsets.all(10.0),
        child: Card(
          elevation: 5,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(8),
          ),
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Container(
              constraints: const BoxConstraints.expand(height: 300),
              alignment: Alignment.center,
              child: (Image.network(
                imageUrl,
                fit: BoxFit.cover,
              )),
            ),
          ),
        ));
  }

che può essere aggiunta ora al nostro widget Column principale:

            children: [
              TextField(...),
              ElevatedButton(...),
              imageCard(result),
            ],

Considerando che la chiamata è asincrona, per completare e migliorare la visualizzazione, possiamo pensare di introdurre un indicatore che ci permetta di capire che l'app sta girando e che è in attesa di una risposta dal web service. Introduciamo quindi una nuova variabile di stato che ci permetta di capire quando la procedura è in attesa di un risultato:


  bool isLoading = false;

e modifichiamo il pulsante in questo modo:


              ElevatedButton(
                  onPressed: () {
                    isLoading = true;
                    result = "";
                    setState(() {});
                    getImage(textController.value.toString()).then((value) {
                      result = json.decode(value.body)['data'][0]['url'];
                    }).whenComplete(() {
                      isLoading = false;
                      setState(() {});
                    });
                  },
                  child: const Text("Genera")),

Ora dopo il click, la variabile isLoading è posta a true e riportata a false solo dopo il completamento della chiamata. Al posto di richiamare semplicemente la funzione imageCard, aggiungiamo una condizione ternaria che tenga conto della variabile introdotta:


              (isLoading)
                  ? const Padding(
                      padding: EdgeInsets.all(8.0),
                      child: CircularProgressIndicator(),
                    )
                  : ((result.isNotEmpty) ? imageCard(result) : Container()),

Questa modifica (e l'aggiornamento dello stato inserito nel pulsante, sia prima, sia dopo la chiamata asincrona) permette di mostrare un indicatore di progresso durante l'attesa e di non mostrare nulla se l'url non è valorizzato.

Non ci resta che racchiudere il body della nostra app in un SingleChildScrollView ed eventualmente aggiungere il giusto padding, per completare il lavoro.



L'applicazione ora è completa e come sempre potete trovare questo codice tra le mie repo GitHub: https://github.com/luigimicco/flutter_openai_image

Commenti