بازیابی متادیتای کلی عکس

مشکلی با عکس‌های آرشیوی‌ام پیش آمده که بد نیست بنویسم برای خود آینده‌ام و رهگذران عزیز.

من عکس‌هایی که با گوشی‌های مختلف گرفته‌ام را به کمک Syncthing با کامپیوترم سینک می‌کنم. در حال حاضر یکسالی می‌شود که روی یک Rock64 فسقلی Libreelec ریخته‌ام و یک هارد پنج‌ترابایتی هم برای سینک به درگاه USB 3 آن متصل کرده‌ام (که ممکن است دیر یا زود به خاطر روشن و خاموش شدن متعدد عمرش را بدهد به شما). سینک‌تینگ روی راک است و فایل‌ها سنک می‌شود روی آن هارددیسک. حالا مشکلی که پیش امده این است که من گوشی جدیدی خریده‌ام و تمام فایل‌ها را روی آن به شکل ترکیبی! (دستی و سینک‌تینگ) کپی کردم. حالا تاریخ آخرین تغییر فایل‌ها به هم ریخته است و وقتی در برنامه‌ی گالری گوشی عکس‌ها را باز می‌کنم تاریخ‌ها اشتباه است. این تغییر نه تنها باعث می‌شود که سورت کردن تصاویر براحتی امکانپذیر نباشد بلکه برنامه‌هایی که از روی عکس‌ها کلیپ‌های کوچک می‌سازند به اشتباه می‌افتند. تصور بکنید در حال تماشای عکس‌های سال‌های گذشته هستیم و به کمک توالی تاریخ عکس‌ها در حال بازسازی گذر ایام در ذهنمان که به یکباره عکسی نامربوط ظاهر می‌شود که دلپذیر نیست.

خلاصه برای حل این مشکل طبق معمول با یک مقدمه دست به دامن خط فرمان می‌شویم.

خبر خوش اینکه در نام همه‌ی عکس‌ها روز عکاسی در فرمت ساده‌ی ISO8601 آمده است. یعنی از چپ به راست اولی تاریخ چهار رقمی و بعد ماه دو رقمی و بعد روز دو رقمی (حالا دیگری هم دارد که از بحث ما خارج است). در برخی فایل‌ها اولی چیز ثابتی آمده و بعد تاریخ که مشکلی نیست. از این گذشته عکس‌ها حاوی اطلاعات Exif هستند که تاریخ اصلی عکاسی در آن ثبت شده است. پس در بدترین حالت می‌توانیم تاریخ را از روی نام فایل‌ها بازسازی کنیم.

داشتن تاریخ یک رویداد در نام فایل به قدری کاربردی است که افزودن تاریخ به فرمتISO8601 به ابتدای نام تمام فایل‌ها به یک عادت دائمی برای من تبدیل شده است. معمولا وقتی قصد کپی گرفتن از یک فایل دارم از دستور زیر استفاده می‌کنم:

$ copy myfile.jpg $(date -I)_myfile.jpg

یا بدون خط تیره در نام فایل:

$ copy myfile.jpg $(date +%+4Y%m%d)_myfile.jpg

حسن این فرمت اینست که نام فایل ابتدا با سال و سپس با ماه و روز شروع می‌شود که کار سورت کردن فایل‌ها را خیلی ساده می‌کند.

ایده‌ی فعلی من برای رفع این ایراد اینست که متادیتای فایل‌هایمان را به کمک اطلاعات اگزیف (یا در نبود آن با کمک نام فایل) اصلاح کنیم و بگذاریم که سینک‌تینگ فایل‌ها را دوباره سینک کند. پیش از آن بیایید نگاهی بکنیم به این متادیتا.

مهم است که بدانیم متادیتا (داده در مورد داده) چیست و کجا ذخیره می‌شود. تاریخ ساخت و تغییر و دسترسی و مانند اینها در حالت عادی داخل فایل ذخیره نمی‌شوند و هر فایل‌سیستم و سیستم‌عامل آن را جداگانه ذخیره می‌کند. فایل‌سیستم ما در این مورد ext4 و سیستم عامل هم ما مبتنی بر لینوکس است. من اطلاعی در مورد ویندوز ندارم اما در سیستم‌عامل‌های مشابه یونیکس متادیتای فایل در ساختاری بنام inode ذخیره می‌شود (از جایی که مک‌اوس هم از خانواده‌ی یونیکس است آنجا هم باید این موارد صادق باشد). به کمک فرمان ls -i می‌توانیم inode یکتای مختص هر فایل را ببینیم. inode اطلاعات مختلفی از جمله حق دسترسی‌ها و همچنین چندین تاریخ (timestamp) مختلف را در خود ذخیره می‌کند. برای مشکلی که ما اینجا با آن روبرو هستیم ctime یا همان creation_time و mtime یا همان modification_time مهم است. اولی تاریخ ساخت و دومی تاریخ آخرین تغییر فایل است. atime یا همان access_time هم وجود دارد که تاریخ آخرین دسترسی یا خوانده شدن محتوای فایل است. در مستندات کرنل لینوکس برای ext4 جزئیات پیاده‌سازی inode در لینوکس آمده است.

نکته‌ی جالبی که در مستندات یاد شده آمده است اینست که متغیری که برای ذخیره‌ی برخی از این تاریخ‌ها استفاده شده چهار بایت (سی و دو بیت) است. نحوه‌ی رایج ذخیره‌ی timestamp یا لحظه‌ی خاص در امتداد محور زمان یک عدد است که تعداد ثانیه‌های سپری شده از بامداد روز اول ژانویه‌ی سال ۱۹۷۰ به وقت گرینویچ در خود ذخیره می‌کند. منتها در سال ۲۰۳۸ تعداد این ثانیه‌ها دیگر در چهار بایت جا نمی‌شود و به اصطلاح overflow یا سرریز می‌کند. برای رفع این مشکل تمهیداتی اندیشیده شده است که شرح آن در مستندات ذکر شده آمده است.

به کمک دو دستور ls و stat می‌توانیم برخی از اطلاعات inode را ببینیم. مثلا بیایید خروجی زیر را برای یکی از عکس‌های خراب من ببینیم:

mx@playground[21250]:/home/mx/Sync/Everything/DCIM/Camera
$ stat 20190928_200752.jpg
stat 20190928_200752.jpg
  File: 20190928_200752.jpg
  Size: 1828942   	Blocks: 3576       IO Block: 4096   regular file
Device: 253,0	Inode: 15409043    Links: 1
Access: (0664/-rw-rw-r--)  Uid: ( 1100/      mx)   Gid: (  985/   users)
Access: 2024-03-01 10:48:49.262525490 +0100
Modify: 2021-10-27 23:04:21.000000000 +0200
Change: 2022-05-14 12:07:15.297708323 +0200
 Birth: 2021-11-08 10:43:23.375113903 +0100
mx@playground[21249]:/home/mx/Sync/Everything/DCIM/Camera
$ ls -l !$
ls -l 20190928_200752.jpg
-rw-rw-r-- 1 mx users 1828942 Oct 27  2021 20190928_200752.jpg

از مقایسه‌ی خروجی stat با ls پیداست که ls تاریخ آخرین تغییر را در خروجی‌اش نمایش می‌دهد (که ۲۷ اکتبر ۲۰۲۱ است).

همانطور که از مقایسه‌ی نام فایل با خروجی دستوارت بالا پیداست ایرادی که برای عکس‌های ما پیش آمده اینست که به طریقی این متاداده حین کپی فایل‌ها از یک کامپیوتر (لینوکس) به دیگری (لینوکس) و در نهایت موبایل تغییر کرده است (اندروید هم لینوکس است البته در مورد گوشی سامسونگم از نوع فایل‌سیستم مطمئن نیستم). مثلا عکسی که سال ۲۰۱۹ گرفته شده است تغییر کرده است به سال ۲۰۲۱ یا ۲۰۲۳. با جستجو در فروم سینک‌تینگ متوجه شدم که سینک‌تینگ تاریخ آخرین تغییر را سینک می‌کند. بنابراین برنامه اصلاح تاریخ آخرین تغییر است. در حقیقت این مسئله به قدری رایج است که برنامه‌های cp و scp و rsync و غیره فلگی برای حفظ متادیتای فایل مرجع دارند. مثلا در مورد cp نگاهی به man cp به ما می‌گوید که -p (حرف اول preserve) باعث حفظ تاریخ‌ها و حق‌دسترسی‌های فایل مرجع می‌شود.

برگردیم سروقت مشکل اصلی. از جایی که متاداده‌های inode عکس‌های ما خراب شده باید تاریخ را یا از نام فایل بگیریم یا از خود فایل. بسته به نوع فایل ممکن است اطلاعاتی اضافه بر داده‌ی خام نیز داخل آن ذخیره شده باشد که می‌شود نوع دوم متاداده. در مورد عکس‌ها (و اصوات ضبط شده در قالب WAV) این قضیه صادق است و برخی انواع فایل مانند JPG و TIF و WAV و PNG و WEBP می‌توانند متاداده با قالب Exif در خود ذخیره کنند (ممکن است انواع دیگری هم باشد که من از آن بی اطلاعم).

برای دیدن متادیتای اگزیف از برنامه‌ای قدیمی به نام exiftool کمک می‌گیریم (که به زبان پرل توسط فیل هاروی نوشته شده است). مثلا به کمک دستور زیر می‌توان تمام تاریخ‌های ذخیره شده در یک فایل سالم را دید:

$ exiftool -time:all -s 20190307_144822.jpg 
FileModifyDate                  : 2019:03:07 14:48:22+01:00
FileAccessDate                  : 2024:02:28 08:25:22+01:00
FileInodeChangeDate             : 2024:02:28 08:25:21+01:00
ModifyDate                      : 2019:03:07 14:48:22
DateTimeOriginal                : 2019:03:07 14:48:22
CreateDate                      : 2019:03:07 14:48:22
SubSecTime                      : 0611
SubSecTimeOriginal              : 0611
SubSecTimeDigitized             : 0611
TimeStamp                       : 2019:03:07 14:48:22.654+01:00
SubSecCreateDate                : 2019:03:07 14:48:22.0611
SubSecDateTimeOriginal          : 2019:03:07 14:48:22.0611
SubSecModifyDate                : 2019:03:07 14:48:22.0611

می‌بینیم که «تگ»های مختلفی در اطلاعات اگزیف وجود دارد (ممکن است exiftool اطلاعات inode را هم به خروجی اضافه کرده باشد). اگر نام فایل را با تاریخ‌های ذخیره شده مقایسه کنیم هم می‌بینیم که تاریخ‌ها درست هستند (بجز تاریخ آخرین دسترسی و تاریخ آخرین تغییر inode). مثال دیگری از یک فایل خراب (دقت کنید که متادیتای کمتری دارد):

$ exiftool -time:all -s 20190928_200752.jpg
FileModifyDate                  : 2021:10:27 23:04:21+02:00
FileAccessDate                  : 2024:01:21 23:56:30+01:00
FileInodeChangeDate             : 2022:05:14 12:07:15+02:00
ModifyDate                      : 2019:09:28 20:07:52
DateTimeOriginal                : 2019:09:28 20:07:52
CreateDate                      : 2019:09:28 20:07:52
TimeStamp                       : 2019:09:28 20:07:52.812+02:00

در این مورد مقایسه نام فایل با FileModifyDate نشان می‌دهد تاریخ آخرین تغییر غلط است (من این فایل‌ها را از زمان عکاسی ویرایش نکرده‌ام البته شاید سهوا کپی کرده باشم). از جایی که با بررسی فایل‌های مختلف متوجه شدم مقدار CreateDate صحیح است بیایید به کمک آن اطلاعات inode عکس‌هایمان را تصحیح کنیم.

برای بیرون کشیدن CreateDate از فرمت زیر کمک می‌گیریم:

$ exiftool -CreateDate -S -d %Y%m%d%H%M.%S -ext jpg 20190307_144822.jpg
CreateDate: 201903071448.22

علت فرمت کردن خروجی اینست که ما می‌خواهیم در گام‌های بعدی آن را به خورد touch بدهیم و این فرمتی است که touch قبول می‌کند. man exiftool را ببینید.

روش کار به کمک دستوارت ساده‌ی یونیکس و همچنین Bash خیلی ساده است:

  1. فایل‌های تمام عکس‌ها را لیست می‌کنیم
  2. روی هر فایل دستور exiftool را اجرا می‌کنیم و CreateDate آنرا در خروجی چاپ می‌کنیم
  3. از خروجی exiftool مقدار دقیق CreateDate را «می‌بریم»
  4. به کمک xargs خروجی cut را به خورد touch می‌دهیم که زمان آخرین تغییر را با مقداری که از exiftool گرفتیم جایگزین می‌کند

هر چهار مرحله‌ی بالا به هم به کمک | به هم وصل شده است. به | می‌گویند «پایپ» یا «لوله». یعنی ما دستورات مختلف (پراسس‌های مختلف) را به یک لوله به هم وصل می‌کنیم (لوله‌کشی می‌کنیم!). کار این لوله وصل کردن خروجی استاندارد هر دستور به ورودی استاندارد دستور بعدی است. لوله اختراع Douglas McIlroy یونیکس‌کار ۹۲ ساله‌ی آمریکایی است و یکی از معجزات یونیکس به شمار می‌رود. جالب است بدانید او در ۹۲ سالگی هنوز هم در میلینگ لیست TUHS فعال است.

برای لیست کردن فایل‌ها در یک حلقه از حلقه‌ی for که در Bash فراهم است کمک می‌گیریم. کافیست به man bash نگاهی بکنیم و چند صفحه پایین برویم تا برسیم به Compound Commands و کمی پایین‌تر نحوه‌ی نوشتن for را پیدا می‌کنیم:

for name [ [ in [ word ... ] ] ; ] do list ; done
    The list of words following in is expanded, generating a list of items.  The variable name is set to each ele‐
    ment of this list in turn, and list is executed each time.  If the in word is omitted, the  for  command  exe‐
    cutes  list  once  for each positional parameter that is set (see PARAMETERS below).  The return status is the
    exit status of the last command that executes.  If the expansion of the items following in results in an empty
    list, no commands are executed, and the return status is 0.

در بدنه‌ی یک حلقه‌ی for می‌شود به آن مقدار یا لغتی که در آن لحظه به آن رسیده‌ام با بکارگیری $name دسترسی داشت. name مقداری است اختیاری که ما انتخاب می‌کنیم. باید دقت داشته باشیم که از دید for هر آنچه در برابرش ظاهر می‌شود چیزی جز یک کلمه نیست. مثلا اگر خروجی دستور ls را به آن بدهیم و نام فایل‌های ما خط فاصله داشته باشد for آنها را چند لغت جدا از هم خواهد دید. این نکته‌ی بسیار مهمی است و برای رفع آن همواره باید متغیرها را درون گیومه قرار داد. بیایید اول یک لیست ساده بنویسیم:

$ for i in $(ls *.jpg | head -n3); do echo "$i"; done
20190307_144822.jpg
20190307_144826.jpg
20190313_172658.jpg

این دستور عکس‌های ما را لیست می‌کند. ما فقط نام فایل را echo کردیم (دستوری که هر آنچه به آن بدهید در خروجی استاندارد خودش می‌نویسد). نام متغیر را i انتخاب کرده‌ایم. علاوه بر آن بجای word مقدار $(ls *.jpg | head) گذاشته‌ایم. Bash هر چیزی که درون $() بگذاریم همیشه به عنوان یک فرمان اجرا می‌کند و با خروجی آن جایگزین می‌کند. ما دستور ls *.jpg | head -n3 داخل آن گذاشته‌ایم. ls که فایل‌های منتهی به .jpg را لیست می‌کند و خروجی آن را به کمک لوله می‌دهد به head که دستور ساده‌ایست که فقط چند خط اول را نگه می‌دارد (چون فایل‌ها زیاد است و برای مثال ما همین کافی بود). جلوتر هم طبق مستندات for دستوری که می‌خواهیم برای هر «لغت» اجرا شود آورده‌ایم که چیزی جز echo "$i" نیست. برای فهم بهتر در نظر بگیرید که دستور بالا دقیقا معادل دستور پایین است:

$ for in in 20190307_144822.jpg 20190307_144826.jpg 20190313_172658.jpg; do echo $i; done
20190322_200524.jpg
20190322_200524.jpg
20190322_200524.jpg

با این مقدمه در مورد for حالا دستور exiftool را در دل آن جای می‌دهیم:

$ for i in $(ls *.jpg | head -n3); do exiftool -CreateDate -S -d %Y%m%d%H%M.%S -ext jpg "$i"; done
CreateDate: 201903071448.22
CreateDate: 201903071448.26
CreateDate: 201903131726.58

به همین ترتیب ادامه می‌دهیم و اینبار خروجی را می‌بریم تا فقط تاریخ فرمت شده باقی بماند:

$ for i in $(ls *.jpg | head -n3); do exiftool -CreateDate -S -d %Y%m%d%H%M.%S -ext jpg "$i" | cut -d ' ' -f 2; done
201903071448.22
201903071448.26
201903131726.58

می‌بینید که دوباره خروجی یک دستور را لوله کرده‌ایم در ورودی دستور بعدی، اینجا دستور cut. این دستور هر خط ورودی‌اش را به کمک یک جداکننده (اینجا خط فاصله) قیچی می‌کند و آنها را به چند ستون تبدیل می‌کند که بعد ما می‌توانیم یک ستون خاص را با پارامتر -f انتخاب کنیم. حالا نام هر فایل و تاریخ صحیح را داریم، ماند اصلاح فایل‌ها.

برای اصلاح فایل از دستور touch استفاده می‌کنیم. man touch به ما می‌گوید که touch می‌توان تاریخ همین لحظه یا یک تاریخ دلخواه را در inode یک فایل بنویسد. تاریخ فرمت‌شده مطابق خواست touch را که قبلا ساختیم و نام فایل را هم که در متغیر i داریم. منتها از جایی که ما می‌خواهیم فایل‌های مختلفی را با تاریخ‌های مختلف اصلاح کنیم باید دستور touch را برای تک‌تک آنها جداگانه اجرا کنیم. به همین منظور از دستور xargs کمک می‌گیریم. xargs برای هر خط که در ورودی استانداردش نوشته بشود یکبار دستوری که به آن در خط فرمان داده‌ایم اجرا می‌کند.

حالا فقط می‌ماند اجرای دستور نهایی. منتها برای تست اول یکبار همه را echo می‌کنیم:

$ for i in $(ls *.jpg | head -n3); do exiftool -CreateDate -S -d %Y%m%d%H%M.%S -ext jpg "$i" | cut -d ' ' -f 2 | xargs echo touc
h "$i" -t; done
touch 20190307_144822.jpg -t 201903071448.22
touch 20190307_144826.jpg -t 201903071448.26
touch 20190313_172658.jpg -t 201903131726.58

این خروجی دستور نهایی است. که خوب به نظر می‌رسد. فقط کافیست head -n3 را به همراه echoی قبل از xargs حذف کنیم و آن را دوباره اجرا کنیم:

$ for i in $(ls *.jpg); do exiftool -CreateDate -S -d %Y%m%d%H%M.%S -ext jpg "$i" | cut -d ' ' -f 2 | xargs touch "$i" -t; done

و تمام.

در این مقاله‌ی کوتاه نگاهی کردیم به متادیتای فایل‌ها در یونیکس و نیز متادیتای اگزیف موجود در عکس‌ها و به کمک مقادیر صحیح تگ CreateDate اگزیف تاریخ آخرین تغییر فایل‌هایمان را اصلاح کردیم تا برنامه‌های گالری عکس بتوانند عکس‌ها را با توالی صحیح نمایش بدهند.