From c69ba54b5af62b6e6b4e941b71a1e15ba7842f3d Mon Sep 17 00:00:00 2001 From: evazion Date: Tue, 21 Sep 2021 10:58:51 -0500 Subject: [PATCH] Fix #4442: Autotag image metadata. Autotag `greyscale`, `non-repeating_animation`, and `exif_rotation`. Note that this does not detect all (or even most) greyscale images. Artists often save greyscale images as RGB instead of as greyscale. --- app/models/media_asset.rb | 46 +++++++++++++++++++++- app/models/post.rb | 6 ++- test/files/test-animated-86x52-loop-1.gif | Bin 0 -> 1102 bytes test/files/test-animated-86x52-loop-2.gif | Bin 0 -> 1121 bytes test/files/test-rotation-90cw.jpg | Bin 0 -> 4184 bytes test/unit/post_test.rb | 32 +++++++++++++++ 6 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 test/files/test-animated-86x52-loop-1.gif create mode 100644 test/files/test-animated-86x52-loop-2.gif create mode 100644 test/files/test-rotation-90cw.jpg diff --git a/app/models/media_asset.rb b/app/models/media_asset.rb index 4f0b65f24..b9e9c7ca2 100644 --- a/app/models/media_asset.rb +++ b/app/models/media_asset.rb @@ -30,4 +30,48 @@ class MediaAsset < ApplicationRecord def is_animated_png? file_ext == "png" && metadata.fetch("PNG:AnimationFrames", 1) > 1 end -end \ No newline at end of file + + # @see https://exiftool.org/TagNames/JPEG.html + # @see https://exiftool.org/TagNames/PNG.html + # @see https://danbooru.donmai.us/posts?tags=exif:File:ColorComponents=1 + # @see https://danbooru.donmai.us/posts?tags=exif:PNG:ColorType=Grayscale + def is_greyscale? + metadata["File:ColorComponents"] == 1 || + metadata["PNG:ColorType"] == "Grayscale" || + metadata["PNG:ColorType"] == "Grayscale with Alpha" + + # Not always accurate: + # metadata["ICC-header:ColorSpaceData"] == "GRAY" || + # metadata["XMP-photoshop:ColorMode"] == "Grayscale" || + # metadata["XMP-photoshop:ICCProfileName"] == "EPSON Gray - Gamma 2.2" || + # metadata["XMP-photoshop:ICCProfileName"] == "Gray Gamma 2.2" + end + + # https://exiftool.org/TagNames/EXIF.html + def is_rotated? + metadata["IFD0:Orientation"].in?(["Rotate 90 CW", "Rotate 270 CW", "Rotate 180"]) || + metadata["IFD1:Orientation"].in?(["Rotate 90 CW", "Rotate 270 CW", "Rotate 180"]) + end + + # Some animations technically have a finite loop count, but loop for hundreds + # or thousands of times. Only count animations with a low loop count as non-repeating. + def is_non_repeating_animation? + loop_count.in?(0..10) + end + + # @see https://exiftool.org/TagNames/GIF.html + # @see https://exiftool.org/TagNames/PNG.html + # @see https://danbooru.donmai.us/posts?tags=-exif:GIF:AnimationIterations=Infinite+animated_gif + # @see https://danbooru.donmai.us/posts?tags=-exif:PNG:AnimationPlays=inf+animated_png + def loop_count + return Float::INFINITY if metadata["GIF:AnimationIterations"] == "Infinite" + return Float::INFINITY if metadata["PNG:AnimationPlays"] == "inf" + return metadata["GIF:AnimationIterations"] if metadata["GIF:AnimationIterations"].present? + return metadata["PNG:AnimationPlays"] if metadata["PNG:AnimationPlays"].present? + + # If the AnimationIterations tag isn't present, then it's counted as a loop count of 0. + return 0 if is_animated_gif? && metadata["GIF:AnimationIterations"].nil? + + nil + end +end diff --git a/app/models/post.rb b/app/models/post.rb index ff124de70..4b70f75fb 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -498,7 +498,7 @@ class Post < ApplicationRecord end def add_automatic_tags(tags) - tags -= %w[incredibly_absurdres absurdres highres lowres huge_filesize flash video ugoira animated_gif animated_png] + tags -= %w[incredibly_absurdres absurdres highres lowres huge_filesize flash video ugoira animated_gif animated_png exif_rotation non-repeating_animation] if image_width >= 10_000 || image_height >= 10_000 tags << "incredibly_absurdres" @@ -543,6 +543,10 @@ class Post < ApplicationRecord tags << "animated_gif" if media_asset.is_animated_gif? tags << "animated_png" if media_asset.is_animated_png? + tags << "greyscale" if media_asset.is_greyscale? + tags << "exif_rotation" if media_asset.is_rotated? + tags << "non-repeating_animation" if media_asset.is_non_repeating_animation? + tags end diff --git a/test/files/test-animated-86x52-loop-1.gif b/test/files/test-animated-86x52-loop-1.gif new file mode 100644 index 0000000000000000000000000000000000000000..966d3a6af71d0d9f6a2107cacc4c8f4c82ff41c2 GIT binary patch literal 1102 zcmZ?wbhEHb3}Y~1_`(1J83r0QHd}0Mx7yilv$x+aA#oxi;zMTU!xah^4Fwe|Is{%^ zFxatUM?=GpO`HC2-u!>dmj5?aO!#qN!-oqSE?oF=<;wpLH%|Qc@#Fvh{~+T~vEok_ zRxSo91|5(v$W8{O=yS9$xFTuR>| zny>ztcjuZY&$A0QR4SMiU%pzp=16U4>_QROJm&nV>v`XprroW$p{aLP^eUfdY*J;b zGLHmLR#T&BBfDsgWd~1No5YN+3B`RAV*8>xW=yM?(aD{Wkx=hs$-PKoNy;)FZc}!~ zosRK48ROa3FWVHhg=hD!fGl<)^%HD78xDJ4xUeFNZSR>gH_kb&zjDco`|-B8I}(hy z*n}8gF#4H4;eHqvd4qSah|n*Qy+8Jayok-5#(lVnfm=i4PI*D2b$km;Sf-ld!h>vF z#%2W`iHz1}Laz%?&-lpX*}ChWQrn!4TzSvwlJ=`&Dph4qvgk8kFiH8m#BQ$j=P$2* zZVnGHXB6S_-5&Dtq9LP%gNlXd>?_N|ZvEtX;-DR#^I}6-W_u&|I+piriA-8%o(*ig zI|`U9nDuUNv%7c9_M(GU^Io<$kNzI~65s3+Xeg0pwZq-2EZqe6?qkLRUUMD9 z4A(Hs$-aB^j?(pm=YrPGGMd}{g?sh_L8~dZL@(~RVIMnvn~ZgMmSSLa;j^`~SuZzD z>aSgAvug+2+_{VARf_$!2vl=ULCU)zci&)HkQ-?LO*yxAElYV=-$R`s$t!^f@`tM7Vv`!APY@X_r=DB2& zLEX&-(~VZ`Na69+Trg#>pXNuoY2j>^2day#ex%RIH?w##TVLuI!`zB%Gt-6mc_utq z!sHUQgmo%kLFTgAdR)zm7w8Ep)v)pyIMlEn@p!dz$to|0l`D2}Wv`yM?^pv<-NUS9 zJU3hy%q@TP^_TVrevW9JjmMaN$JWpP*4n>$x7By4nDY``YPoS-CI!p4U5tyK73^91 zIyY0f<8>4V13af_F{m+!Ft9T)uxcz&?OU9dmeCcp?AWgL(@tq>AIe+3s%-xJ-Jdmj5?aO!#qN!-oqSE?oF=<;wpLH%|Qc@#Fvh{~+T~vEok_ zRxSo92F3r}ey$Qu+?jeD%k?JJ&>co?Wn^Qo*eF^3~EcM`}A`7mB#%G3QTR z&-=zS?QX>lO}(?CSNTL^lPX)4c_etUni@qL*+pwCJ9ygKBxZC?DDImO+ZWX_V_Lo3b`h_K=qs4H+dIR4hbiUs)b@>nGO}2kr2j z7aPJd+Z(ypvAkzXWYRM8Y+&QvQNUcmtap2x-MwSB7ag>k_p-ft^!MPG_-2)XS}=lKu*NIV(5hMn8)o#2FnjT}nTWKWZnSDg3XiAef+=(TG(XBs3um)DP+es8BYjT3 znZ=9Q`cl6b=2l#rnJ&c7GvUD!CYPustW)_4GMCNP<7!^KKu=JqhLy*_p@#K{$E%e~ zR(UzBT(OHQd-c41#~PUG9%e1$x#7BCZuz6HzqB{-b42TGJjV1pwtoJ%*8a`At-ede zoR{EI%Z=kQDOkSkVqEmBV9(OmxtYoxucJ5^V3|ipi$RS+gn^xbfmLIHYTx3tw2ZE( zWyf}{pLR-9`%vEMRb}(%?-qS0Hv40nnQYfKrHNAx3LrF_F{m*JgEgxyQ0-d;(%dJN znRSzUy-M{t!`SHjzH>U)by;;d6L&mXx7@3h0io3qu2mDJHENmVwwcS;-HM*~cIHO2 a?(@5&UaC!A*t2Khd5-9baaIW&4AuZ{(b^vX literal 0 HcmV?d00001 diff --git a/test/files/test-rotation-90cw.jpg b/test/files/test-rotation-90cw.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fc2b837c4e06cdc9a005748f4138a8c7e808d039 GIT binary patch literal 4184 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 t*wSX!;`AIZY2rJqs=5TXQA1c-#NK2>_bGk&plY literal 0 HcmV?d00001 diff --git a/test/unit/post_test.rb b/test/unit/post_test.rb index b9548cf3c..44502e4f2 100644 --- a/test/unit/post_test.rb +++ b/test/unit/post_test.rb @@ -1318,6 +1318,38 @@ class PostTest < ActiveSupport::TestCase end end + context "a greyscale image missing the greyscale tag" do + should "automatically add the greyscale tag" do + @media_asset = MediaAsset.create!(file: "test/files/test-grey-no-profile.jpg") + @post.update!(md5: @media_asset.md5) + @post.reload.update!(tag_string: "tagme") + assert_equal("greyscale tagme", @post.tag_string) + end + end + + context "an exif-rotated image missing the exif_rotation tag" do + should "automatically add the exif_rotation tag" do + @media_asset = MediaAsset.create!(file: "test/files/test-rotation-90cw.jpg") + @post.update!(md5: @media_asset.md5) + @post.reload.update!(tag_string: "tagme") + assert_equal("exif_rotation tagme", @post.tag_string) + end + end + + context "a non-repeating GIF missing the non-repeating_animation tag" do + should "automatically add the non-repeating_animation tag" do + @media_asset = MediaAsset.create!(file: "test/files/test-animated-86x52-loop-1.gif") + @post.update!(md5: @media_asset.md5) + @post.reload.update!(tag_string: "tagme") + assert_equal("animated animated_gif non-repeating_animation tagme", @post.tag_string) + + @media_asset = MediaAsset.create!(file: "test/files/test-animated-86x52-loop-2.gif") + @post.update!(md5: @media_asset.md5) + @post.reload.update!(tag_string: "tagme") + assert_equal("animated animated_gif non-repeating_animation tagme", @post.tag_string) + end + end + should "have an array representation of its tags" do post = FactoryBot.create(:post) post.reload