Initial Flutter MVP for bottle-cap mosaic generator
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.dart_tool/
|
||||||
|
.packages
|
||||||
|
.pub/
|
||||||
|
build/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
57
README.md
Normal file
57
README.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Korken Mosaic (Flutter MVP)
|
||||||
|
|
||||||
|
Prototype Flutter app for generating bottle-cap mosaics from imported images.
|
||||||
|
|
||||||
|
## Implemented MVP
|
||||||
|
|
||||||
|
- Import target image from gallery (`image_picker`)
|
||||||
|
- Resolution controls:
|
||||||
|
- explicit grid width/height
|
||||||
|
- or auto grid by approximate cap size in source image pixels
|
||||||
|
- Cap palette management:
|
||||||
|
- list caps with name + color
|
||||||
|
- add color via picker and/or manual hex
|
||||||
|
- remove caps
|
||||||
|
- Mosaic generation:
|
||||||
|
- resize source to grid
|
||||||
|
- nearest cap color match using CIELAB + DeltaE (CIE76)
|
||||||
|
- fallback concept is RGB distance, but LAB path is implemented directly
|
||||||
|
- Output:
|
||||||
|
- mosaic grid preview
|
||||||
|
- bill of materials counts per cap color
|
||||||
|
|
||||||
|
## Current blocker on this machine
|
||||||
|
|
||||||
|
`flutter` SDK is not installed (`flutter: command not found`), so I could not run:
|
||||||
|
|
||||||
|
- `flutter create`
|
||||||
|
- `flutter pub get`
|
||||||
|
- `flutter analyze`
|
||||||
|
- `flutter build apk --debug`
|
||||||
|
|
||||||
|
## Setup commands (Ubuntu/Debian)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/yadciel/.openclaw/workspace
|
||||||
|
sudo snap install flutter --classic
|
||||||
|
# OR: install manually from flutter.dev and add to PATH
|
||||||
|
|
||||||
|
flutter doctor
|
||||||
|
|
||||||
|
cd /home/yadciel/.openclaw/workspace/korken_mosaic
|
||||||
|
flutter create .
|
||||||
|
flutter pub get
|
||||||
|
flutter run
|
||||||
|
flutter build apk --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected APK artifact:
|
||||||
|
|
||||||
|
`build/app/outputs/flutter-apk/app-debug.apk`
|
||||||
|
|
||||||
|
## Project files
|
||||||
|
|
||||||
|
- `lib/main.dart` – complete MVP UI + mosaic logic
|
||||||
|
- `pubspec.yaml` – dependencies
|
||||||
|
- `analysis_options.yaml`
|
||||||
|
- `.gitignore`
|
||||||
1
analysis_options.yaml
Normal file
1
analysis_options.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
369
lib/main.dart
Normal file
369
lib/main.dart
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
runApp(const KorkenMosaicApp());
|
||||||
|
}
|
||||||
|
|
||||||
|
class KorkenMosaicApp extends StatelessWidget {
|
||||||
|
const KorkenMosaicApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'Korken Mosaic',
|
||||||
|
theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
|
||||||
|
home: const MosaicHomePage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MosaicHomePage extends StatefulWidget {
|
||||||
|
const MosaicHomePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MosaicHomePage> createState() => _MosaicHomePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||||
|
final ImagePicker _picker = ImagePicker();
|
||||||
|
final TextEditingController _gridWidthCtrl = TextEditingController(text: '40');
|
||||||
|
final TextEditingController _gridHeightCtrl = TextEditingController(text: '30');
|
||||||
|
final TextEditingController _capSizeCtrl = TextEditingController(text: '12');
|
||||||
|
|
||||||
|
Uint8List? _sourceImageBytes;
|
||||||
|
MosaicResult? _result;
|
||||||
|
bool _useCapSize = false;
|
||||||
|
|
||||||
|
final List<CapColor> _palette = [
|
||||||
|
CapColor(name: 'White', color: const Color(0xFFF2F2F2)),
|
||||||
|
CapColor(name: 'Black', color: const Color(0xFF222222)),
|
||||||
|
CapColor(name: 'Red', color: const Color(0xFFD84343)),
|
||||||
|
CapColor(name: 'Blue', color: const Color(0xFF3F6FD8)),
|
||||||
|
CapColor(name: 'Green', color: const Color(0xFF4FAE63)),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_gridWidthCtrl.dispose();
|
||||||
|
_gridHeightCtrl.dispose();
|
||||||
|
_capSizeCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickImage() async {
|
||||||
|
final XFile? picked = await _picker.pickImage(source: ImageSource.gallery);
|
||||||
|
if (picked == null) return;
|
||||||
|
final bytes = await picked.readAsBytes();
|
||||||
|
setState(() {
|
||||||
|
_sourceImageBytes = bytes;
|
||||||
|
_result = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addCapDialog() {
|
||||||
|
Color selected = Colors.orange;
|
||||||
|
final nameCtrl = TextEditingController();
|
||||||
|
final hexCtrl = TextEditingController(text: '#${selected.toARGB32().toRadixString(16).substring(2).toUpperCase()}');
|
||||||
|
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Add Bottle Cap Color'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: nameCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Name'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: hexCtrl,
|
||||||
|
decoration: const InputDecoration(labelText: 'Hex (#RRGGBB)'),
|
||||||
|
onChanged: (value) {
|
||||||
|
final parsed = _parseHex(value);
|
||||||
|
if (parsed != null) selected = parsed;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ColorPicker(
|
||||||
|
pickerColor: selected,
|
||||||
|
onColorChanged: (c) {
|
||||||
|
selected = c;
|
||||||
|
hexCtrl.text = '#${c.toARGB32().toRadixString(16).substring(2).toUpperCase()}';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final name = nameCtrl.text.trim();
|
||||||
|
if (name.isEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
_palette.add(CapColor(name: name, color: _parseHex(hexCtrl.text) ?? selected));
|
||||||
|
});
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
child: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _generate() {
|
||||||
|
if (_sourceImageBytes == null || _palette.isEmpty) return;
|
||||||
|
final decoded = img.decodeImage(_sourceImageBytes!);
|
||||||
|
if (decoded == null) return;
|
||||||
|
|
||||||
|
final int gridW;
|
||||||
|
final int gridH;
|
||||||
|
if (_useCapSize) {
|
||||||
|
final capSize = math.max(1, int.tryParse(_capSizeCtrl.text) ?? 12);
|
||||||
|
gridW = math.max(1, (decoded.width / capSize).round());
|
||||||
|
gridH = math.max(1, (decoded.height / capSize).round());
|
||||||
|
} else {
|
||||||
|
gridW = math.max(1, int.tryParse(_gridWidthCtrl.text) ?? 40);
|
||||||
|
gridH = math.max(1, int.tryParse(_gridHeightCtrl.text) ?? 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
final scaled = img.copyResize(decoded, width: gridW, height: gridH, interpolation: img.Interpolation.average);
|
||||||
|
final paletteLab = _palette.map((p) => _rgbToLab(p.color)).toList();
|
||||||
|
|
||||||
|
final List<int> assignments = List<int>.filled(gridW * gridH, 0);
|
||||||
|
final counts = <CapColor, int>{};
|
||||||
|
|
||||||
|
for (int y = 0; y < gridH; y++) {
|
||||||
|
for (int x = 0; x < gridW; x++) {
|
||||||
|
final pix = scaled.getPixel(x, y);
|
||||||
|
final srcColor = Color.fromARGB(255, pix.r.toInt(), pix.g.toInt(), pix.b.toInt());
|
||||||
|
final srcLab = _rgbToLab(srcColor);
|
||||||
|
|
||||||
|
int bestIdx = 0;
|
||||||
|
double bestDistance = double.infinity;
|
||||||
|
for (int i = 0; i < _palette.length; i++) {
|
||||||
|
final d = _deltaE76(srcLab, paletteLab[i]);
|
||||||
|
if (d < bestDistance) {
|
||||||
|
bestDistance = d;
|
||||||
|
bestIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assignments[y * gridW + x] = bestIdx;
|
||||||
|
final cap = _palette[bestIdx];
|
||||||
|
counts[cap] = (counts[cap] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_result = MosaicResult(width: gridW, height: gridH, assignments: assignments, palette: List.of(_palette), counts: counts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Bottle-Cap Mosaic Prototype')),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: _generate,
|
||||||
|
icon: const Icon(Icons.auto_fix_high),
|
||||||
|
label: const Text('Generate Mosaic'),
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: ListView(
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
runSpacing: 8,
|
||||||
|
spacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _pickImage,
|
||||||
|
icon: const Icon(Icons.image_outlined),
|
||||||
|
label: const Text('Import target image'),
|
||||||
|
),
|
||||||
|
if (_sourceImageBytes != null) const Text('Image loaded ✅'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SegmentedButton<bool>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: false, label: Text('Grid W x H')),
|
||||||
|
ButtonSegment(value: true, label: Text('Cap size (px)')),
|
||||||
|
],
|
||||||
|
selected: {_useCapSize},
|
||||||
|
onSelectionChanged: (s) => setState(() => _useCapSize = s.first),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (!_useCapSize)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _gridWidthCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(labelText: 'Grid Width'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _gridHeightCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(labelText: 'Grid Height'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
TextField(
|
||||||
|
controller: _capSizeCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(labelText: 'Approx cap size in source image (pixels)'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(child: Text('Cap Palette', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))),
|
||||||
|
IconButton(onPressed: _addCapDialog, icon: const Icon(Icons.add_circle_outline)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < _palette.length; i++)
|
||||||
|
Chip(
|
||||||
|
avatar: CircleAvatar(backgroundColor: _palette[i].color),
|
||||||
|
label: Text(_palette[i].name),
|
||||||
|
onDeleted: _palette.length <= 1 ? null : () => setState(() => _palette.removeAt(i)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (_result != null) ...[
|
||||||
|
Text('Preview (${_result!.width} x ${_result!.height})', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: _result!.width / _result!.height,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: MosaicPainter(_result!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Bill of Materials', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
...(() {
|
||||||
|
final items = _result!.counts.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
|
||||||
|
return items
|
||||||
|
.map(
|
||||||
|
(e) => ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: CircleAvatar(backgroundColor: e.key.color),
|
||||||
|
title: Text(e.key.name),
|
||||||
|
trailing: Text('${e.value} caps'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
})(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color? _parseHex(String raw) {
|
||||||
|
final hex = raw.trim().replaceFirst('#', '');
|
||||||
|
if (hex.length != 6) return null;
|
||||||
|
final value = int.tryParse(hex, radix: 16);
|
||||||
|
if (value == null) return null;
|
||||||
|
return Color(0xFF000000 | value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CapColor {
|
||||||
|
final String name;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
CapColor({required this.name, required this.color});
|
||||||
|
}
|
||||||
|
|
||||||
|
class MosaicResult {
|
||||||
|
final int width;
|
||||||
|
final int height;
|
||||||
|
final List<int> assignments;
|
||||||
|
final List<CapColor> palette;
|
||||||
|
final Map<CapColor, int> counts;
|
||||||
|
|
||||||
|
MosaicResult({required this.width, required this.height, required this.assignments, required this.palette, required this.counts});
|
||||||
|
}
|
||||||
|
|
||||||
|
class MosaicPainter extends CustomPainter {
|
||||||
|
final MosaicResult result;
|
||||||
|
|
||||||
|
MosaicPainter(this.result);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final cellW = size.width / result.width;
|
||||||
|
final cellH = size.height / result.height;
|
||||||
|
final paint = Paint();
|
||||||
|
|
||||||
|
for (int y = 0; y < result.height; y++) {
|
||||||
|
for (int x = 0; x < result.width; x++) {
|
||||||
|
final idx = result.assignments[y * result.width + x];
|
||||||
|
paint.color = result.palette[idx].color;
|
||||||
|
canvas.drawRect(Rect.fromLTWH(x * cellW, y * cellH, cellW, cellH), paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant MosaicPainter oldDelegate) => oldDelegate.result != result;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<double> _rgbToLab(Color c) {
|
||||||
|
double r = c.r / 255.0;
|
||||||
|
double g = c.g / 255.0;
|
||||||
|
double b = c.b / 255.0;
|
||||||
|
|
||||||
|
r = r <= 0.04045 ? r / 12.92 : math.pow((r + 0.055) / 1.055, 2.4).toDouble();
|
||||||
|
g = g <= 0.04045 ? g / 12.92 : math.pow((g + 0.055) / 1.055, 2.4).toDouble();
|
||||||
|
b = b <= 0.04045 ? b / 12.92 : math.pow((b + 0.055) / 1.055, 2.4).toDouble();
|
||||||
|
|
||||||
|
final x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047;
|
||||||
|
final y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 1.00000;
|
||||||
|
final z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883;
|
||||||
|
|
||||||
|
double f(double t) => t > 0.008856 ? math.pow(t, 1.0 / 3.0).toDouble() : (7.787 * t) + (16 / 116);
|
||||||
|
|
||||||
|
final fx = f(x);
|
||||||
|
final fy = f(y);
|
||||||
|
final fz = f(z);
|
||||||
|
|
||||||
|
final l = (116 * fy) - 16;
|
||||||
|
final a = 500 * (fx - fy);
|
||||||
|
final bStar = 200 * (fy - fz);
|
||||||
|
|
||||||
|
return [l, a, bStar];
|
||||||
|
}
|
||||||
|
|
||||||
|
double _deltaE76(List<double> lab1, List<double> lab2) {
|
||||||
|
final dl = lab1[0] - lab2[0];
|
||||||
|
final da = lab1[1] - lab2[1];
|
||||||
|
final db = lab1[2] - lab2[2];
|
||||||
|
return math.sqrt(dl * dl + da * da + db * db);
|
||||||
|
}
|
||||||
23
pubspec.yaml
Normal file
23
pubspec.yaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: korken_mosaic
|
||||||
|
description: Prototype app to create bottle-cap mosaics from photos.
|
||||||
|
publish_to: 'none'
|
||||||
|
version: 0.1.0+1
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ">=3.2.0 <4.0.0"
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
cupertino_icons: ^1.0.8
|
||||||
|
image_picker: ^1.1.2
|
||||||
|
image: ^4.2.0
|
||||||
|
flutter_colorpicker: ^1.1.0
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^4.0.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
Reference in New Issue
Block a user