seika 0.1.0 copy "seika: ^0.1.0" to clipboard
seika: ^0.1.0 copied to clipboard

Smart inpainting for Flutter. Routes automatically between PatchMatch and LaMa neural based on mask complexity analysis.

example/lib/main.dart

import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image/image.dart' as img;
import 'package:seika/seika.dart';
import 'package:seika_example/model_catalog_sheet.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: MainScreen());
  }
}

class MainScreen extends StatefulWidget {
  const MainScreen({super.key});
  @override
  State<MainScreen> createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
  final _seika = Seika();

  String _status = 'Load a photo to get started';
  SeikaAnalysis? _analysis;
  ui.Image? _originalImage;
  ui.Image? _outputImage;
  bool _loading = false;

  Uint8List? _imageRgba;
  Uint8List? _mask;
  int _imgW = 0, _imgH = 0;

  // Model
  SeikaModelInfo _selectedModel = SeikaModelInfo.lama;
  final Map<String, bool> _modelAvailable = {};
  bool _forceNeural = false;

  double _brushRadius = 20.0;
  bool _erasing = false;
  bool _isPainting = false;

  @override
  void initState() {
    super.initState();
    _checkModel();
  }

  @override
  void dispose() {
    _seika.unloadModel();
    super.dispose();
  }

  Future<void> _checkModel() async {
    for (final m in SeikaModelInfo.all) {
      final ok = await SeikaModelManager(info: m).isAvailable();
      if (mounted) setState(() => _modelAvailable[m.id] = ok);
    }
    if (!mounted) return;
    if (_modelAvailable[_selectedModel.id] != true) {
      final available = SeikaModelInfo.all
          .where((m) => _modelAvailable[m.id] == true)
          .firstOrNull;
      if (available != null) await _selectModel(available);
    }
  }

  Future<void> _selectModel(SeikaModelInfo model) async {
    await _seika.switchModel(model);
    if (mounted) setState(() => _selectedModel = model);
  }

  Future<void> _pickImage() async {
    final picker = ImagePicker();
    final picked = await picker.pickImage(
      source: ImageSource.gallery,
      maxWidth: 512,
      maxHeight: 512,
    );
    if (picked == null) return;

    setState(() {
      _loading = true;
      _status = 'Loading image...';
    });

    final bytes = await picked.readAsBytes();
    final decoded = img.decodeImage(bytes);
    if (decoded == null) {
      setState(() {
        _loading = false;
        _status = 'Error decoding image';
      });
      return;
    }

    final rgba = Uint8List(decoded.width * decoded.height * 4);
    for (int y = 0; y < decoded.height; y++) {
      for (int x = 0; x < decoded.width; x++) {
        final p = decoded.getPixel(x, y);
        final i = (y * decoded.width + x) * 4;
        rgba[i] = p.r.toInt();
        rgba[i + 1] = p.g.toInt();
        rgba[i + 2] = p.b.toInt();
        rgba[i + 3] = 255;
      }
    }

    final uiImg = await _toUiImage(rgba, decoded.width, decoded.height);
    setState(() {
      _imageRgba = rgba;
      _imgW = decoded.width;
      _imgH = decoded.height;
      _mask = Uint8List(_imgW * _imgH);
      _originalImage = uiImg;
      _outputImage = null;
      _analysis = null;
      _loading = false;
      _erasing = false;
      _status = 'Draw on the image to mark the area to remove';
    });
  }

  void _onPanStart(DragStartDetails d, Size displaySize) {
    setState(() => _isPainting = true);
    _paintAt(d.localPosition, displaySize);
  }

  void _onPanUpdate(DragUpdateDetails d, Size displaySize) =>
      _paintAt(d.localPosition, displaySize);

  void _onPanEnd(DragEndDetails _, Size _) {
    setState(() => _isPainting = false);
    if (_mask != null && !_mask!.every((v) => v == 0)) {
      setState(() => _status = 'Mask defined — choose inpainting quality');
    }
  }

  void _paintAt(Offset localPos, Size displaySize) {
    if (_imageRgba == null || _mask == null) return;

    final scaleX = _imgW / displaySize.width;
    final scaleY = _imgH / displaySize.height;
    final imgX = localPos.dx * scaleX;
    final imgY = localPos.dy * scaleY;
    final rImg = (_brushRadius * scaleX).round().clamp(1, 200);

    bool changed = false;
    for (int dy = -rImg; dy <= rImg; dy++) {
      for (int dx = -rImg; dx <= rImg; dx++) {
        if (dx * dx + dy * dy > rImg * rImg) continue;
        final px = (imgX + dx).round();
        final py = (imgY + dy).round();
        if (px < 0 || px >= _imgW || py < 0 || py >= _imgH) continue;
        _mask![py * _imgW + px] = _erasing ? 0 : 255;
        changed = true;
      }
    }
    if (changed) _updateOriginalWithOverlay();
  }

  Future<void> _updateOriginalWithOverlay() async {
    if (_imageRgba == null || _mask == null) return;
    final uiImg = await _toUiImage(
      _applyMaskOverlay(_imageRgba!, _mask!),
      _imgW,
      _imgH,
    );
    if (mounted) setState(() => _originalImage = uiImg);
  }

  Future<void> _runInpaint(SeikaQuality quality) async {
    if (_imageRgba == null || _mask == null) {
      setState(() => _status = 'Load an image and define a mask first');
      return;
    }
    if (_mask!.every((v) => v == 0)) {
      setState(() => _status = 'Draw on the image to mark the area');
      return;
    }

    setState(() {
      _loading = true;
      _status = 'Processing...';
    });

    try {
      final result = await _seika.inpaint(
        _imageRgba!,
        _mask!,
        _imgW,
        _imgH,
        options: SeikaInpaintOptions(
          engine: _forceNeural ? SeikaEngine.neural : SeikaEngine.auto,
          quality: quality,
        ),
      );
      final uiOutput = await _toUiImage(result.image, _imgW, _imgH);
      setState(() {
        _outputImage = uiOutput;
        _analysis = result.analysis;
        _loading = false;
        _status =
            '✅ ${quality.name} · ${result.engineUsed.name}'
            ' · ${result.duration.inMilliseconds}ms';
      });
    } on SeikaModelNotAvailableException {
      setState(() {
        _loading = false;
        _forceNeural = false;
        _status = 'Model not available — download it first';
      });
    } catch (e) {
      setState(() {
        _loading = false;
        _status = 'Error: $e';
      });
    }
  }

  Uint8List _applyMaskOverlay(Uint8List image, Uint8List mask) {
    final out = Uint8List.fromList(image);
    for (int i = 0; i < mask.length; i++) {
      if (mask[i] > 128) {
        out[i * 4] = 255;
        out[i * 4 + 1] = 0;
        out[i * 4 + 2] = 0;
        out[i * 4 + 3] = 200;
      }
    }
    return out;
  }

  Future<ui.Image> _toUiImage(Uint8List rgba, int w, int h) {
    final c = Completer<ui.Image>();
    ui.decodeImageFromPixels(rgba, w, h, ui.PixelFormat.rgba8888, c.complete);
    return c.future;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Seika — Inpainting Demo'),
        backgroundColor: Colors.black87,
        foregroundColor: Colors.white,
      ),
      backgroundColor: Colors.grey[100],
      body: SingleChildScrollView(
        physics: _isPainting
            ? const NeverScrollableScrollPhysics()
            : const ClampingScrollPhysics(),
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // Status bar
            Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: Colors.black87,
                borderRadius: BorderRadius.circular(8),
              ),
              child: Text(
                _status,
                style: const TextStyle(color: Colors.white, fontSize: 13),
                textAlign: TextAlign.center,
              ),
            ),
            if (_loading) ...[
              const SizedBox(height: 8),
              const LinearProgressIndicator(),
            ],

            const SizedBox(height: 12),
            _modelStatusBar(),
            const SizedBox(height: 12),

            // Images
            if (_originalImage != null) ...[
              Row(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Expanded(child: _paintableImageCard()),
                  const SizedBox(width: 8),
                  Expanded(
                    child: _outputImage != null
                        ? _imageCard('Result', _outputImage!)
                        : _placeholder('Not processed yet'),
                  ),
                ],
              ),
              _brushControls(),
            ] else
              _placeholder('Load a photo to get started'),

            const SizedBox(height: 12),
            if (_analysis != null) _analysisCard(_analysis!),
            const SizedBox(height: 12),

            // Controls
            ElevatedButton.icon(
              onPressed: _loading ? null : _pickImage,
              icon: const Icon(Icons.photo_library),
              label: const Text('Load photo'),
            ),
            const SizedBox(height: 8),
            const Text(
              'Inpainting:',
              style: TextStyle(fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 6),
            Row(
              children: [
                _inpaintBtn('Fast', SeikaQuality.fast, Colors.blue),
                const SizedBox(width: 6),
                _inpaintBtn('Balanced', SeikaQuality.balanced, Colors.green),
                const SizedBox(width: 6),
                _inpaintBtn('Quality', SeikaQuality.quality, Colors.purple),
              ],
            ),
            if (_imageRgba != null) ...[
              const SizedBox(height: 8),
              OutlinedButton.icon(
                onPressed: _loading
                    ? null
                    : () {
                        setState(() {
                          _mask = Uint8List(_imgW * _imgH);
                          _outputImage = null;
                          _erasing = false;
                          _status = 'Mask cleared';
                        });
                        _updateOriginalWithOverlay();
                      },
                icon: const Icon(Icons.refresh),
                label: const Text('Clear mask'),
              ),
            ],
          ],
        ),
      ),
    );
  }

  Widget _modelStatusBar() {
    final anyAvailable = SeikaModelInfo.all.any(
      (m) => _modelAvailable[m.id] == true,
    );

    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
      decoration: BoxDecoration(
        color: Colors.grey[50],
        borderRadius: BorderRadius.circular(8),
        border: Border.all(color: Colors.grey[300]!),
      ),
      child: Row(
        children: [
          Icon(
            anyAvailable ? Icons.check_circle : Icons.cloud_download_outlined,
            size: 16,
            color: anyAvailable ? Colors.green : Colors.orange,
          ),
          const SizedBox(width: 8),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  anyAvailable
                      ? '${_selectedModel.name} — active'
                      : 'No model downloaded',
                  style: const TextStyle(
                    fontWeight: FontWeight.w600,
                    fontSize: 13,
                  ),
                ),
                if (anyAvailable)
                  Text(
                    _selectedModel.tags.take(2).join(' · '),
                    style: TextStyle(fontSize: 11, color: Colors.grey[600]),
                  )
                else
                  Text(
                    'Tap "Models" to download',
                    style: TextStyle(fontSize: 11, color: Colors.grey[600]),
                  ),
              ],
            ),
          ),
          if (anyAvailable) ...[
            ToggleButtons(
              isSelected: [!_forceNeural, _forceNeural],
              onPressed: (i) => setState(() => _forceNeural = i == 1),
              borderRadius: BorderRadius.circular(6),
              constraints: const BoxConstraints(minWidth: 52, minHeight: 28),
              textStyle: const TextStyle(fontSize: 11),
              children: [const Text('Auto'), Text(_selectedModel.name)],
            ),
            const SizedBox(width: 8),
          ],
          ElevatedButton(
            onPressed: _openModelCatalog,
            style: ElevatedButton.styleFrom(
              backgroundColor: Colors.black87,
              foregroundColor: Colors.white,
              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
              minimumSize: Size.zero,
              textStyle: const TextStyle(fontSize: 12),
            ),
            child: const Text('Models'),
          ),
        ],
      ),
    );
  }

  void _openModelCatalog() {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.transparent,
      builder: (_) => ModelCatalogSheet(
        selectedModel: _selectedModel,
        initialAvailable: Map.from(_modelAvailable),
        onModelSelected: (model) async {
          await _selectModel(model);
          setState(() {});
        },
        onModelDeleted: (model) {
          setState(() {
            _modelAvailable[model.id] = false;
            if (_selectedModel == model) {
              _selectedModel = SeikaModelInfo.lama;
              _seika.unloadModel();
            }
          });
        },
        onModelDownloaded: (model) async {
          setState(() => _modelAvailable[model.id] = true);
          final noActive = !SeikaModelInfo.all.any(
            (m) => m == _selectedModel && (_modelAvailable[m.id] ?? false),
          );
          if (noActive) await _selectModel(model);
        },
      ),
    );
  }

  Widget _paintableImageCard() {
    return Column(
      children: [
        const Text(
          'Original + mask',
          style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
        ),
        const SizedBox(height: 4),
        LayoutBuilder(
          builder: (context, constraints) {
            final displayW = constraints.maxWidth;
            final displayH = _imgH > 0 ? displayW * _imgH / _imgW : displayW;
            final displaySize = Size(displayW, displayH);

            return GestureDetector(
              behavior: HitTestBehavior.opaque,
              onPanStart: (d) => _onPanStart(d, displaySize),
              onPanUpdate: (d) => _onPanUpdate(d, displaySize),
              onPanEnd: (d) => _onPanEnd(d, displaySize),
              child: Container(
                width: displayW,
                height: displayH,
                decoration: BoxDecoration(
                  border: Border.all(
                    color: _erasing ? Colors.red[400]! : Colors.blue,
                    width: 2,
                  ),
                  borderRadius: BorderRadius.circular(4),
                ),
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(4),
                  child: RawImage(
                    image: _originalImage!,
                    width: displayW,
                    height: displayH,
                    fit: BoxFit.fill,
                  ),
                ),
              ),
            );
          },
        ),
      ],
    );
  }

  Widget _brushControls() {
    return Padding(
      padding: const EdgeInsets.only(top: 8),
      child: Row(
        children: [
          GestureDetector(
            onTap: () => setState(() => _erasing = !_erasing),
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
              decoration: BoxDecoration(
                color: _erasing ? Colors.red[50] : Colors.blue[50],
                borderRadius: BorderRadius.circular(6),
                border: Border.all(
                  color: _erasing ? Colors.red[300]! : Colors.blue[300]!,
                ),
              ),
              child: Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Icon(
                    _erasing ? Icons.auto_fix_off : Icons.brush,
                    size: 15,
                    color: _erasing ? Colors.red[400] : Colors.blue[400],
                  ),
                  const SizedBox(width: 5),
                  Text(
                    _erasing ? 'Erase' : 'Paint',
                    style: TextStyle(
                      fontSize: 12,
                      fontWeight: FontWeight.w600,
                      color: _erasing ? Colors.red[400] : Colors.blue[400],
                    ),
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(width: 8),
          Expanded(
            child: Row(
              children: [
                const Icon(Icons.circle_outlined, size: 10, color: Colors.grey),
                Expanded(
                  child: Slider(
                    value: _brushRadius,
                    min: 5,
                    max: 60,
                    divisions: 11,
                    label: '${_brushRadius.round()}px',
                    onChanged: (v) => setState(() => _brushRadius = v),
                  ),
                ),
                const Icon(Icons.circle, size: 20, color: Colors.grey),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _imageCard(String label, ui.Image image) => Column(
    children: [
      Text(
        label,
        style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
      ),
      const SizedBox(height: 4),
      Container(
        decoration: BoxDecoration(
          border: Border.all(color: Colors.grey),
          borderRadius: BorderRadius.circular(4),
        ),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(4),
          child: RawImage(image: image, fit: BoxFit.contain),
        ),
      ),
    ],
  );

  Widget _placeholder(String msg) => Container(
    height: 120,
    decoration: BoxDecoration(
      color: Colors.grey[300],
      borderRadius: BorderRadius.circular(4),
    ),
    child: Center(
      child: Text(msg, style: TextStyle(color: Colors.grey[600], fontSize: 12)),
    ),
  );

  Widget _analysisCard(SeikaAnalysis a) => Container(
    padding: const EdgeInsets.all(12),
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(8),
      border: Border.all(color: Colors.grey[300]!),
    ),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text('Analysis', style: TextStyle(fontWeight: FontWeight.bold)),
        const SizedBox(height: 8),
        _row('Mask ratio', a.maskRatio.toStringAsFixed(3)),
        _row('Variance', a.textureVariance.toStringAsFixed(1)),
        _row('Coherence', a.edgeCoherence.toStringAsFixed(3)),
        _row('Engine rec.', a.recommended.name),
      ],
    ),
  );

  Widget _row(String k, String v) => Padding(
    padding: const EdgeInsets.symmetric(vertical: 2),
    child: Row(
      children: [
        Text('$k: ', style: const TextStyle(fontWeight: FontWeight.w500)),
        Text(v),
      ],
    ),
  );

  Widget _inpaintBtn(String label, SeikaQuality q, Color color) => Expanded(
    child: ElevatedButton(
      onPressed: _loading ? null : () => _runInpaint(q),
      style: ElevatedButton.styleFrom(
        backgroundColor: color,
        foregroundColor: Colors.white,
      ),
      child: Text(label),
    ),
  );
}
1
likes
150
points
12
downloads

Documentation

API reference

Publisher

verified publishergearscrafter.dev

Weekly Downloads

Smart inpainting for Flutter. Routes automatically between PatchMatch and LaMa neural based on mask complexity analysis.

Repository (GitHub)
View/report issues

Topics

#image #inpainting #machine-learning #onnx #computer-vision

License

Apache-2.0 (license)

Dependencies

code_assets, crypto, ffi, flutter, hooks, http, image, logging, native_toolchain_c, onnxruntime_v2, path_provider

More

Packages that depend on seika