Skip to content

Commit

Permalink
feat: #304 - new method setProductImageAngle and setProductImageCrop (#…
Browse files Browse the repository at this point in the history
…309)

Deleted file:
* `corn_da.jpg`

Impacted files:
* `api_addProductImage_test.dart`: added tests for `OpenFoodAPIClient.setProductImageAngle` and `OpenFoodAPIClient.setProductImageCrop`; removed a duplicate test; minor refactoring
* `ImageHelper.dart`: added methods `getProductImageRootUrl` and `getProductImageFilename`; minor refactoring
* `JsonHelper.dart`: decoded new fields `imgid`, `angle` and crop fields of `ProductImage` for product `'images`''; minor refactoring
* `openfoodfacts.dart`: new methods `setProductImageAngle` and `setProductImageCrop`; minor refactoring
* `ProductHelper.dart`: minor refactoring
* `ProductImage.dart`: added `enum ImageAngle`; added `imgid`, `angle`, crop fields `coordinatesImageSize`, `x1`, `y1`, `x2` and `y2` to `ProductImage`
* `UriHelper.dart`: unrelated refactoring
  • Loading branch information
monsieurtanuki committed Dec 9, 2021
1 parent 85169f0 commit c69f3f6
Show file tree
Hide file tree
Showing 8 changed files with 468 additions and 86 deletions.
94 changes: 92 additions & 2 deletions lib/model/ProductImage.dart
Expand Up @@ -61,15 +61,105 @@ extension ImageSizeExtension on ImageSize? {
);
}

/// Angle for image rotation.
enum ImageAngle {
/// Noon = no rotation
NOON,

/// 3 o'clock
THREE_O_CLOCK,

/// 6 o'clock
SIX_O_CLOCK,

/// 9 o'clock
NINE_O_CLOCK,
}

extension ImageAngleExtension on ImageAngle {
static const Map<ImageAngle, int> _DEGREES_CLOCKWISE = {
ImageAngle.NOON: 0,
ImageAngle.THREE_O_CLOCK: 90,
ImageAngle.SIX_O_CLOCK: 180,
ImageAngle.NINE_O_CLOCK: 270,
};

String get degreesClockwise => _DEGREES_CLOCKWISE[this]?.toString() ?? '0';

/// Returns the corresponding [ImageAngle], or null if not found.
static ImageAngle? fromInt(final int? clockwiseDegree) {
for (final MapEntry<ImageAngle, int> entry in _DEGREES_CLOCKWISE.entries) {
if (entry.value == clockwiseDegree) {
return entry.key;
}
}
return null;
}
}

/// The url to a specific product image.
/// Categorized by content type, size and language
class ProductImage {
ProductImage(
{required this.field, this.size, this.language, this.url, this.rev});
ProductImage({
required this.field,
this.size,
this.language,
this.url,
this.rev,
this.imgid,
this.angle,
this.coordinatesImageSize,
this.x1,
this.y1,
this.x2,
this.y2,
});

final ImageField field;
final ImageSize? size;
final OpenFoodFactsLanguage? language;
String? url;

/// Revision number
int? rev;

/// Uploaded image id (probably an `int`)
String? imgid;

/// Image angle, compared to the uploaded image
ImageAngle? angle;

/// On what size are the coordinates ([x1], ...)computed? 'full' or '400'
String? coordinatesImageSize;

/// Crop coordinate x1, compared to the uploaded image
int? x1;

/// Crop coordinate y1, compared to the uploaded image
int? y1;

/// Crop coordinate x2, compared to the uploaded image
int? x2;

/// Crop coordinate y2, compared to the uploaded image
int? y2;

@override
String toString() =>
'ProductImage('
'${field.value}' +
(size == null ? '' : ',size=${size.value}]') +
(language == null ? '' : ',language=${language.code}') +
(angle == null ? '' : ',angle=${angle!.degreesClockwise}') +
(url == null ? '' : ',url=$url') +
(imgid == null ? '' : ',imgid=$imgid') +
(rev == null ? '' : ',rev=$rev') +
(coordinatesImageSize == null
? ''
: ',coordinatesImageSize=$coordinatesImageSize') +
(x1 == null ? '' : ',x1=$x1') +
(y1 == null ? '' : ',y1=$y1') +
(x2 == null ? '' : ',x2=$x2') +
(y2 == null ? '' : ',y2=$y2') +
')';
}
117 changes: 117 additions & 0 deletions lib/openfoodfacts.dart
Expand Up @@ -9,6 +9,7 @@ import 'package:openfoodfacts/interface/JsonObject.dart';
import 'package:openfoodfacts/model/KnowledgePanels.dart';
import 'package:openfoodfacts/model/OcrIngredientsResult.dart';
import 'package:openfoodfacts/model/OrderedNutrients.dart';
import 'package:openfoodfacts/model/ProductImage.dart';
import 'package:openfoodfacts/model/TaxonomyAdditive.dart';
import 'package:openfoodfacts/model/TaxonomyAllergen.dart';
import 'package:openfoodfacts/model/TaxonomyCategory.dart';
Expand All @@ -18,6 +19,7 @@ import 'package:openfoodfacts/model/TaxonomyLabel.dart';
import 'package:openfoodfacts/model/TaxonomyLanguage.dart';
import 'package:openfoodfacts/utils/AbstractQueryConfiguration.dart';
import 'package:openfoodfacts/utils/CountryHelper.dart';
import 'package:openfoodfacts/utils/ImageHelper.dart';
import 'package:openfoodfacts/utils/OcrField.dart';
import 'package:openfoodfacts/utils/OpenFoodAPIConfiguration.dart';
import 'package:openfoodfacts/utils/PnnsGroupQueryConfiguration.dart';
Expand Down Expand Up @@ -907,4 +909,119 @@ class OpenFoodAPIClient {
final json = jsonDecode(response.body);
return OrderedNutrients.fromJson(json);
}

/// Rotates a product image from an already uploaded image.
///
/// "I want, for this [barcode], this [imageField] and this [language],
/// the image to be computed from the already uploaded image
/// referenced by [imgid], with a rotation of [angle].
///
/// Returns the URL to the "display" picture after the operation.
/// Returns null if KO, but would probably throw an exception instead.
static Future<String?> setProductImageAngle({
required final String barcode,
required final ImageField imageField,
required final OpenFoodFactsLanguage language,
required final String imgid,
required final ImageAngle angle,
final QueryType? queryType,
}) async =>
await _callProductImageCrop(
barcode: barcode,
imageField: imageField,
language: language,
imgid: imgid,
extraParameters: <String, String>{
'angle': angle.degreesClockwise,
},
);

/// Crops a product image from an already uploaded image.
///
/// "I want, for this [barcode], this [imageField] and this [language],
/// the image to be computed from the already uploaded image
/// referenced by [imgid], with a possible rotation of [angle] and then
/// a cropping on rectangle ([x1],[y1],[x2],[y2]), those coordinates
/// being taken from the uploaded image size.
///
/// Returns the URL to the "display" picture after the operation.
/// Returns null if KO, but would probably throw an exception instead.
static Future<String?> setProductImageCrop({
required final String barcode,
required final ImageField imageField,
required final OpenFoodFactsLanguage language,
required final String imgid,
required final int x1,
required final int y1,
required final int x2,
required final int y2,
final ImageAngle angle = ImageAngle.NOON,
final QueryType? queryType,
}) async =>
await _callProductImageCrop(
barcode: barcode,
imageField: imageField,
language: language,
imgid: imgid,
extraParameters: <String, String>{
'x1': x1.toString(),
'y1': y1.toString(),
'x2': x2.toString(),
'y2': y2.toString(),
'angle': angle.degreesClockwise,
'coordinates_image_size': 'full',
},
);

/// Calls `cgi/product_image_crop.pl` on a [ProductImage].
///
/// Returns the URL to the "display" picture after the operation.
/// Returns null if KO, but would probably throw an exception instead.
static Future<String?> _callProductImageCrop({
required final String barcode,
required final ImageField imageField,
required final OpenFoodFactsLanguage language,
required final String imgid,
required final Map<String, String> extraParameters,
final QueryType? queryType,
}) async {
final String id = '${imageField.value}_${language.code}';
final Map<String, String> queryParameters = <String, String>{
'code': barcode,
'id': id,
'imgid': imgid,
};
queryParameters.addAll(extraParameters);
final Uri uri = UriHelper.getUri(
path: 'cgi/product_image_crop.pl',
queryType: queryType,
queryParameters: queryParameters,
);

final Response response = await HttpHelper()
.doGetRequest(uri, userAgent: OpenFoodAPIConfiguration.userAgent);
if (response.statusCode != 200) {
throw Exception(
'Bad response (${response.statusCode}): ${response.body}');
}
final Map<String, dynamic> json =
jsonDecode(response.body) as Map<String, dynamic>;
final String status = json['status'];
if (status != 'status ok') {
throw Exception('Status not ok ($status)');
}
final String imagefield = json['imagefield'];
if (imagefield != id) {
throw Exception(
'Different imagefield: expected "$id", actual "$imageField"');
}
final Map<String, dynamic> images = json['image'];
final String? filename = images['display_url'];
if (filename == null) {
return null;
}
return ImageHelper.getProductImageRootUrl(barcode, queryType: queryType) +
'/' +
filename;
}
}
71 changes: 48 additions & 23 deletions lib/utils/ImageHelper.dart
@@ -1,4 +1,5 @@
import 'package:image/image.dart';
import 'package:openfoodfacts/utils/OpenFoodAPIConfiguration.dart';
import 'package:openfoodfacts/utils/QueryType.dart';
import '../model/ProductImage.dart';
import 'LanguageHelper.dart';
Expand Down Expand Up @@ -26,36 +27,60 @@ class ImageHelper {
}
}

/// Returns the image url
/// E.g. https://static.openfoodfacts.org/images/products/359/671/046/2858/front_fr.4.100.jpg"
static String? buildUrl(String? barcode, ProductImage image,
{QueryType queryType = QueryType.PROD}) {
if (barcode == null) {
return null;
}
String barcodeUrl = barcode;
/// Returns the product image full url, or null if [barcode] is null
///
/// E.g. "https://static.openfoodfacts.org/images/products/359/671/046/2858/front_fr.4.100.jpg"
static String? buildUrl(
final String? barcode,
final ProductImage image, {
final QueryType? queryType,
}) =>
barcode == null
? null
: getProductImageRootUrl(barcode, queryType: queryType) +
'/' +
image.field.value +
'_' +
image.language.code +
'.' +
image.rev.toString() +
'.' +
image.size.toNumber() +
'.jpg';

/// Returns the product image filename
///
/// E.g. "front_fr.4.100.jpg"
static String getProductImageFilename(final ProductImage image) =>
image.field.value +
'_' +
image.language.code +
'.' +
image.rev.toString() +
'.' +
image.size.toNumber() +
'.jpg';

/// Returns the web folder of the product images (without trailing '/')
///
/// E.g. "https://static.openfoodfacts.org/images/products/359/671/046/2858"
static String getProductImageRootUrl(
final String barcode, {
final QueryType? queryType,
}) {
final String barcodeUrl;
if (barcode.length >= 9) {
var p1 = barcode.substring(0, 3);
var p2 = barcode.substring(3, 6);
var p3 = barcode.substring(6, 9);
var p4 = barcode.length >= 10 ? barcode.substring(9) : '';

barcodeUrl = p1 + '/' + p2 + '/' + p3 + '/' + p4;
} else {
barcodeUrl = barcode;
}

String urlHelper = barcodeUrl +
'/' +
image.field.value +
'_' +
image.language.code +
'.' +
image.rev.toString() +
'.' +
image.size.toNumber() +
'.jpg';

return queryType == QueryType.PROD
? IMAGE_PROD_URL_BASE + urlHelper
: IMAGE_TEST_URL_BASE + urlHelper;
return OpenFoodAPIConfiguration.getQueryType(queryType) == QueryType.PROD
? IMAGE_PROD_URL_BASE + barcodeUrl
: IMAGE_TEST_URL_BASE + barcodeUrl;
}
}
32 changes: 26 additions & 6 deletions lib/utils/JsonHelper.dart
Expand Up @@ -72,14 +72,23 @@ class JsonHelper {
for (var field in ImageField.values) {
for (OpenFoodFactsLanguage lang in OpenFoodFactsLanguage.values) {
// get the field object e.g. front_en
String fieldName = field.value + '_' + lang.code;
final String fieldName = field.value + '_' + lang.code;
if (json[fieldName] == null) continue;

var fieldObject = json[fieldName] as Map<String, dynamic>?;
final fieldObject = json[fieldName] as Map<String, dynamic>?;
if (fieldObject == null) continue;

// get the rev object
var rev = JsonObject.parseInt(fieldObject['rev']);
final rev = JsonObject.parseInt(fieldObject['rev']);
final String imgid = fieldObject['imgid'].toString();
final ImageAngle? angle = ImageAngleExtension.fromInt(
JsonObject.parseInt(fieldObject['angle']),
);
final String? coordinatesImageSize =
fieldObject['coordinates_image_size'];
final int? x1 = JsonObject.parseInt(fieldObject['x1']);
final int? y1 = JsonObject.parseInt(fieldObject['y1']);
final int? x2 = JsonObject.parseInt(fieldObject['x2']);
final int? y2 = JsonObject.parseInt(fieldObject['y2']);

// get the sizes object
var sizesObject = fieldObject['sizes'] as Map<String, dynamic>?;
Expand All @@ -91,8 +100,19 @@ class JsonHelper {
var numberObject = sizesObject[number] as Map<String, dynamic>?;
if (numberObject == null) continue;

var image =
ProductImage(field: field, size: size, language: lang, rev: rev);
var image = ProductImage(
field: field,
size: size,
language: lang,
rev: rev,
imgid: imgid,
angle: angle,
coordinatesImageSize: coordinatesImageSize,
x1: x1,
y1: y1,
x2: x2,
y2: y2,
);
imageList.add(image);
}
}
Expand Down
3 changes: 1 addition & 2 deletions lib/utils/ProductHelper.dart
@@ -1,6 +1,5 @@
import 'package:openfoodfacts/utils/ImageHelper.dart';
import 'package:openfoodfacts/utils/LanguageHelper.dart';
import 'package:openfoodfacts/utils/OpenFoodAPIConfiguration.dart';
import 'package:openfoodfacts/utils/QueryType.dart';

import '../model/Product.dart';
Expand Down Expand Up @@ -36,7 +35,7 @@ class ProductHelper {
image.url = ImageHelper.buildUrl(
product.barcode,
image,
queryType: OpenFoodAPIConfiguration.getQueryType(queryType),
queryType: queryType,
);
}
}
Expand Down

0 comments on commit c69f3f6

Please sign in to comment.