seika 0.0.1
seika: ^0.0.1 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),
),
);
}