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
--
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