From 3e4ad0f993ed0e633ef3eb02eaf076639d7d1c7b Mon Sep 17 00:00:00 2001 From: hennikul Date: Mon, 31 Aug 2026 18:30:57 +0200 Subject: [PATCH] crop: apply the aspect ratio dialed in on the camera Cameras that let you pick an aspect ratio in the viewfinder record that choice in the raw file without destroying the surrounding pixels. Other editors reproduce the framing on open and let you take it back; we showed the full sensor readout and dropped the intent on the floor. Read the ratio into the image and let the crop module center it on the sensor area, enabling itself so the framing is there right away while staying free to be moved around within the full frame. We deliberately keep the ratio alone instead of the box the camera reports. That box is centered on the output area of the camera, which is not quite the area we hand to the pipe, so reusing it would mean matching every vendor's idea of the full frame. Centering a ratio ourselves sidesteps that, and it is also the only thing some vendors record. The module stays off when the ratio it works out is the one it was handed anyway, so a vendor that applied the crop to the raw data after all, or a value read as something it is not, does nothing rather than cropping the image wrongly. Canon and Olympus are read for now, but the reader is a table of per-vendor functions and everything downstream is brand agnostic, so adding a vendor is one function and one line. Nikon needs nothing, it writes only the cropped area. Fuji and Sony are not reachable through Exiv2 and want doing in rawspeed instead. A DefaultUserCrop coming from the raw keeps its current behaviour of only preloading the parameters, untouched. Tested on an EOS R6 across 1:1, 4:3 and 16:9 in landscape and portrait with native 3:2 left alone, and on Olympus E-M10 Mark IV (16:9), E-P2 (3:2), TG-6 (3:2) and E-PL9 (native 4:3) samples from raw.pixls.us. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 5 +++ src/common/exif.cc | 98 +++++++++++++++++++++++++++++++++++++++++++++- src/common/image.c | 1 + src/common/image.h | 4 ++ src/iop/crop.c | 61 +++++++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 1 deletion(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7ce35d9a27b..d717d2836ea 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -138,6 +138,11 @@ changes (where available). - pixelpipe dump files requested via cli switches are now written in ppm or pgm format. +- The aspect ratio chosen on the camera is now applied as a crop when + the raw was left uncropped, so a frame shot at 1:1 or 16:9 opens + framed as intended while the full sensor area stays available to + reframe within. Read from Canon and Olympus raws. + ## Bug Fixes - Clarified multi-image rating toasts for un-reject and for mixed diff --git a/src/common/exif.cc b/src/common/exif.cc index 7bb3332805e..68576c259c5 100644 --- a/src/common/exif.cc +++ b/src/common/exif.cc @@ -956,6 +956,99 @@ static bool _check_usercrop(Exiv2::ExifData &exifData, return FALSE; } +// Support the aspect ratio dialed in on the camera while shooting. Every +// vendor keeps it somewhere else in its own makernote, but what we need out +// of it is the same everywhere: the ratio, and whether it differs from the +// native one at all. +// +// The box a camera reports is always centered on the output area of that +// camera, which is a little smaller than the sensor area we process. We +// therefore keep the ratio alone and leave the centering to the crop module, +// so we never have to match the vendor's idea of the full frame. +// +// To support another vendor, write a reader filling d:n and add it below. + +// Canon keeps it in AspectInfo as (ratio, width, height, left, top). +// Magic-nr taken from the makernote spec, Exiv2 knows the tag by name +// only from 0.27.4 on. +static bool _camera_aspect_canon(Exiv2::ExifData &exifData, + int *d, + int *n) +{ + Exiv2::ExifData::const_iterator pos = + exifData.findKey(Exiv2::ExifKey("Exif.Canon.0x009a")); + + if(pos == exifData.end() || pos->count() != 5 || !pos->size()) + return FALSE; + + // a box sitting at the origin covers the full frame, nothing was cropped + if(pos->toFloat(3) <= 0.0f && pos->toFloat(4) <= 0.0f) + return FALSE; + + switch((int)pos->toFloat(0)) + { + case 1: *d = 1; *n = 1; break; // 1:1 + case 2: *d = 4; *n = 3; break; // 4:3 + case 7: *d = 16; *n = 9; break; // 16:9 + case 12: *d = 185; *n = 100; break; // 1.85:1 + default: return FALSE; // 0 is native, the rest is unknown + } + + return TRUE; +} + +// Olympus keeps it in the image processing block as two bytes: the ratio, +// and whether the raw data was cropped along with it. Only the second form +// concerns us, the other one has nothing left to reproduce. +static bool _camera_aspect_olympus(Exiv2::ExifData &exifData, + int *d, + int *n) +{ + Exiv2::ExifData::const_iterator pos = + exifData.findKey(Exiv2::ExifKey("Exif.OlympusIp.0x1112")); + + if(pos == exifData.end() || pos->count() != 2 || !pos->size()) + return FALSE; + + // the second byte tells the raw data was left uncropped + if((int)pos->toFloat(1) != 1) + return FALSE; + + switch((int)pos->toFloat(0)) + { + case 2: *d = 3; *n = 2; break; // 3:2 + case 3: *d = 16; *n = 9; break; // 16:9 + case 4: *d = 1; *n = 1; break; // 1:1 + default: return FALSE; // 1 is the native 4:3, the rest is unknown + } + + return TRUE; +} + +static bool _check_camera_aspect(Exiv2::ExifData &exifData, + dt_image_t *img) +{ + static bool (*const readers[])(Exiv2::ExifData &, int *, int *) = + { + _camera_aspect_canon, + _camera_aspect_olympus, + }; + + for(size_t i = 0; i < sizeof(readers) / sizeof(readers[0]); i++) + { + int d = 0, n = 0; + + if(readers[i](exifData, &d, &n) && d > 0 && n > 0) + { + img->camera_ratio_d = d; + img->camera_ratio_n = n; + return TRUE; + } + } + + return FALSE; +} + static void _check_linear_response_limit(Exiv2::ExifData &exifData, dt_image_t *img) { @@ -1404,6 +1497,7 @@ void dt_exif_img_check_additional_tags(dt_image_t *img, if(!exifData.empty()) { _check_usercrop(exifData, img); + _check_camera_aspect(exifData, img); _check_dng_opcodes(exifData, img); _check_lens_correction_data(exifData, img); _check_linear_response_limit(exifData, img); @@ -1719,7 +1813,9 @@ static bool _exif_decode_exif_data(dt_image_t *img, Exiv2::ExifData &exifData) } } - if(_check_usercrop(exifData, img)) + // both are read, so no short-circuiting here + const bool has_usercrop = _check_usercrop(exifData, img); + if(_check_camera_aspect(exifData, img) || has_usercrop) { img->flags |= DT_IMAGE_HAS_ADDITIONAL_EXIF_TAGS; guint tagid = 0; diff --git a/src/common/image.c b/src/common/image.c index c3ba33c43e7..d7e31943651 100644 --- a/src/common/image.c +++ b/src/common/image.c @@ -2254,6 +2254,7 @@ void dt_image_init(dt_image_t *img) img->wb_coeffs[3] = NAN; img->usercrop[0] = img->usercrop[1] = 0; img->usercrop[2] = img->usercrop[3] = 1; + img->camera_ratio_d = img->camera_ratio_n = 0; img->dng_gain_maps = NULL; img->exif_correction_type = CORRECTION_TYPE_NONE; memset(&img->exif_correction_data, 0, sizeof(img->exif_correction_data)); diff --git a/src/common/image.h b/src/common/image.h index b889c4e5963..546af1217cf 100644 --- a/src/common/image.h +++ b/src/common/image.h @@ -358,6 +358,10 @@ typedef struct dt_image_t /* DefaultUserCrop */ dt_boundingbox_t usercrop; + /* aspect ratio dialed in on the camera while shooting, as d:n following + the crop module convention; 0:0 if the native sensor ratio was used */ + int32_t camera_ratio_d, camera_ratio_n; + /* GainMaps from DNG OpcodeList2 exif tag */ GList *dng_gain_maps; diff --git a/src/iop/crop.c b/src/iop/crop.c index 1c6df871c65..a975fd61c64 100644 --- a/src/iop/crop.c +++ b/src/iop/crop.c @@ -889,6 +889,67 @@ void reload_defaults(dt_iop_module_t *self) dp->cw = img->usercrop[3]; dp->ch = img->usercrop[2]; dp->ratio_n = dp->ratio_d = -1; + + self->default_enabled = FALSE; + + // A crop coming from the raw only preloads our parameters. An aspect ratio + // dialed in on the camera though is a framing decision taken while + // shooting, so reproduce it right away: center that ratio on the sensor + // area we are handed and switch ourselves on. The intended framing is + // applied but stays free to be moved around within the full frame. + const gboolean has_usercrop = dp->cx > 0.0f || dp->cy > 0.0f + || dp->cw < 1.0f || dp->ch < 1.0f; + + const float iwd = img->p_width > 0 ? img->p_width : img->width; + const float iht = img->p_height > 0 ? img->p_height : img->height; + + if(has_usercrop + || img->camera_ratio_d < 1 + || img->camera_ratio_n < 1 + || iwd < 1.0f + || iht < 1.0f) + return; + + // p_width and p_height are still in sensor orientation here + const float want = (float)img->camera_ratio_d / (float)img->camera_ratio_n; + const float have = iwd / iht; + + float bx = 0.0f, by = 0.0f, bw = 1.0f, bh = 1.0f; + if(want < have) // narrower than the sensor, take off left and right + { + bw = want / have; + bx = (1.0f - bw) * 0.5f; + } + else if(want > have) // wider, take off top and bottom + { + bh = have / want; + by = (1.0f - bh) * 0.5f; + } + + // The ratio is the one we are handed already, either because the vendor + // applied the crop to the raw data after all or because the value was read + // as something it is not. Nothing to reproduce, and staying off is the safe + // answer rather than adding a crop that does nothing. + if(bw > 0.995f && bh > 0.995f) + return; + + // flip runs before us, so follow the image into portrait. The box is + // centered, which leaves the mirroring bits of the orientation a no-op. + if(dt_image_orientation(img) & ORIENTATION_SWAP_XY) + { + float t; + t = bx; bx = by; by = t; + t = bw; bw = bh; bh = t; + } + + dp->cx = bx; + dp->cy = by; + dp->cw = bx + bw; + dp->ch = by + bh; + dp->ratio_d = img->camera_ratio_d; + dp->ratio_n = img->camera_ratio_n; + + self->default_enabled = TRUE; } static void _float_to_fract(const char *num, int *n, int *d)