كتابة مجموعات البيانات المخصصة

اتبع هذا الدليل لإنشاء مجموعة بيانات جديدة (إما في TFDS أو في المستودع الخاص بك).

تحقق من قائمة مجموعات البيانات لدينا لمعرفة ما إذا كانت مجموعة البيانات التي تريدها موجودة بالفعل.

ليرة تركية؛ د

أسهل طريقة لكتابة مجموعة بيانات جديدة هي استخدام TFDS CLI :

cd path/to/my/project/datasets/
tfds new my_dataset  # Create `my_dataset/my_dataset.py` template files
# [...] Manually modify `my_dataset/my_dataset_dataset_builder.py` to implement your dataset.
cd my_dataset/
tfds build  # Download and prepare the dataset to `~/tensorflow_datasets/`

لاستخدام مجموعة البيانات الجديدة مع tfds.load('my_dataset') :

  • سوف يقوم tfds.load تلقائيًا باكتشاف وتحميل مجموعة البيانات التي تم إنشاؤها في ~/tensorflow_datasets/my_dataset/ (على سبيل المثال بواسطة tfds build ).
  • وبدلاً من ذلك، يمكنك import my.project.datasets.my_dataset بشكل صريح لتسجيل مجموعة البيانات الخاصة بك:
import my.project.datasets.my_dataset  # Register `my_dataset`

ds = tfds.load('my_dataset')  # `my_dataset` registered

ملخص

يتم توزيع مجموعات البيانات بجميع أنواع التنسيقات وفي جميع أنواع الأماكن، ولا يتم تخزينها دائمًا بتنسيق جاهز للتغذية في مسار التعلم الآلي. أدخل تي اف دي اس.

تقوم TFDS بمعالجة مجموعات البيانات هذه في تنسيق قياسي (بيانات خارجية -> ملفات متسلسلة)، والتي يمكن بعد ذلك تحميلها كخط أنابيب للتعلم الآلي (ملفات متسلسلة -> tf.data.Dataset ). يتم إجراء التسلسل مرة واحدة فقط. سيتم قراءة الوصول اللاحق من تلك الملفات التي تمت معالجتها مسبقًا مباشرة.

تتم معظم عمليات المعالجة المسبقة تلقائيًا. تطبق كل مجموعة بيانات فئة فرعية من tfds.core.DatasetBuilder ، والتي تحدد:

  • من أين تأتي البيانات (أي عناوين URL الخاصة بها)؛
  • كيف تبدو مجموعة البيانات (أي ميزاتها)؛
  • كيف ينبغي تقسيم البيانات (على سبيل المثال، TRAIN TEST
  • والأمثلة الفردية في مجموعة البيانات.

اكتب مجموعة البيانات الخاصة بك

القالب الافتراضي: tfds new

استخدم TFDS CLI لإنشاء ملفات بايثون القالب المطلوبة.

cd path/to/project/datasets/  # Or use `--dir=path/to/project/datasets/` below
tfds new my_dataset

سيقوم هذا الأمر بإنشاء مجلد my_dataset/ جديد بالبنية التالية:

my_dataset/
    __init__.py
    README.md # Markdown description of the dataset.
    CITATIONS.bib # Bibtex citation for the dataset.
    TAGS.txt # List of tags describing the dataset.
    my_dataset_dataset_builder.py # Dataset definition
    my_dataset_dataset_builder_test.py # Test
    dummy_data/ # (optional) Fake data (used for testing)
    checksum.tsv # (optional) URL checksums (see `checksums` section).

ابحث عن TODO(my_dataset) هنا وقم بالتعديل وفقًا لذلك.

مثال على مجموعة البيانات

يتم تنفيذ جميع مجموعات البيانات ضمن فئات فرعية من tfds.core.DatasetBuilder ، الذي يعتني بمعظم البيانات المعيارية. وهو يدعم:

  • مجموعات البيانات الصغيرة والمتوسطة التي يمكن إنشاؤها على جهاز واحد (هذا البرنامج التعليمي).
  • مجموعات بيانات كبيرة جدًا تتطلب إنشاءًا موزعًا (باستخدام Apache Beam ، راجع دليل مجموعة البيانات الضخمة الخاص بنا)

فيما يلي مثال بسيط لمنشئ مجموعة البيانات الذي يعتمد على tfds.core.GeneratorBasedBuilder :

class Builder(tfds.core.GeneratorBasedBuilder):
  """DatasetBuilder for my_dataset dataset."""

  VERSION = tfds.core.Version('1.0.0')
  RELEASE_NOTES = {
      '1.0.0': 'Initial release.',
  }

  def _info(self) -> tfds.core.DatasetInfo:
    """Dataset metadata (homepage, citation,...)."""
    return self.dataset_info_from_configs(
        features=tfds.features.FeaturesDict({
            'image': tfds.features.Image(shape=(256, 256, 3)),
            'label': tfds.features.ClassLabel(
                names=['no', 'yes'],
                doc='Whether this is a picture of a cat'),
        }),
    )

  def _split_generators(self, dl_manager: tfds.download.DownloadManager):
    """Download the data and define splits."""
    extracted_path = dl_manager.download_and_extract('http://data.org/data.zip')
    # dl_manager returns pathlib-like objects with `path.read_text()`,
    # `path.iterdir()`,...
    return {
        'train': self._generate_examples(path=extracted_path / 'train_images'),
        'test': self._generate_examples(path=extracted_path / 'test_images'),
    }

  def _generate_examples(self, path) -> Iterator[Tuple[Key, Example]]:
    """Generator of examples for each split."""
    for img_path in path.glob('*.jpeg'):
      # Yields (key, example)
      yield img_path.name, {
          'image': img_path,
          'label': 'yes' if img_path.name.startswith('yes_') else 'no',
      }

لاحظ أنه بالنسبة لبعض تنسيقات البيانات المحددة، فإننا نوفر أدوات إنشاء مجموعات بيانات جاهزة للاستخدام للاهتمام بمعظم عمليات معالجة البيانات.

دعونا نرى بالتفصيل الطرق الثلاثة المجردة للكتابة فوق.

_info : البيانات الوصفية لمجموعة البيانات

تقوم _info بإرجاع tfds.core.DatasetInfo الذي يحتوي على البيانات التعريفية لمجموعة البيانات .

def _info(self):
  # The `dataset_info_from_configs` base method will construct the
  # `tfds.core.DatasetInfo` object using the passed-in parameters and
  # adding: builder (self), description/citations/tags from the config
  # files located in the same package.
  return self.dataset_info_from_configs(
      homepage='https://dataset-homepage.org',
      features=tfds.features.FeaturesDict({
          'image_description': tfds.features.Text(),
          'image': tfds.features.Image(),
          # Here, 'label' can be 0-4.
          'label': tfds.features.ClassLabel(num_classes=5),
      }),
      # If there's a common `(input, target)` tuple from the features,
      # specify them here. They'll be used if as_supervised=True in
      # builder.as_dataset.
      supervised_keys=('image', 'label'),
      # Specify whether to disable shuffling on the examples. Set to False by default.
      disable_shuffling=False,
  )

يجب أن تكون معظم الحقول واضحة بذاتها. بعض الدقة:

كتابة ملف BibText CITATIONS.bib :

  • ابحث في موقع مجموعة البيانات عن تعليمات الاقتباس (استخدم ذلك بتنسيق BibTex).
  • بالنسبة لأوراق arXiv : ابحث عن الورقة وانقر فوق رابط BibText الموجود على الجانب الأيمن.
  • ابحث عن الورقة البحثية في Google Scholar وانقر على علامة الاقتباس المزدوجة الموجودة أسفل العنوان وفي النافذة المنبثقة، انقر على BibTeX .
  • إذا لم يكن هناك ورق مرتبط (على سبيل المثال، يوجد موقع ويب فقط)، يمكنك استخدام محرر BibTeX عبر الإنترنت لإنشاء إدخال BibTeX مخصص (تحتوي القائمة المنسدلة على نوع إدخال Online ).

تحديث ملف TAGS.txt :

  • تتم تعبئة جميع العلامات المسموح بها مسبقًا في الملف الذي تم إنشاؤه.
  • قم بإزالة كافة العلامات التي لا تنطبق على مجموعة البيانات.
  • يتم إدراج العلامات الصالحة في Tensorflow_datasets/core/valid_tags.txt .
  • لإضافة علامة إلى تلك القائمة، يرجى إرسال PR.

الحفاظ على ترتيب مجموعة البيانات

افتراضيًا، يتم خلط سجلات مجموعات البيانات عند تخزينها من أجل جعل توزيع الفئات أكثر اتساقًا عبر مجموعة البيانات، نظرًا لأن السجلات التي تنتمي إلى نفس الفئة غالبًا ما تكون متجاورة. من أجل تحديد أنه يجب فرز مجموعة البيانات حسب المفتاح الذي تم إنشاؤه بواسطة _generate_examples يجب تعيين الحقل disable_shuffling على True . افتراضيًا، يتم تعيينه على False .

def _info(self):
  return self.dataset_info_from_configs(
    # [...]
    disable_shuffling=True,
    # [...]
  )

ضع في اعتبارك أن تعطيل التبديل العشوائي له تأثير على الأداء حيث لا يمكن قراءة الأجزاء بشكل متوازٍ بعد الآن.

_split_generators : تنزيل البيانات وتقسيمها

تحميل واستخراج البيانات المصدر

تحتاج معظم مجموعات البيانات إلى تنزيل البيانات من الويب. ويتم ذلك باستخدام وسيطة الإدخال tfds.download.DownloadManager الخاصة بـ _split_generators . لدى dl_manager الطرق التالية:

  • download : يدعم http(s):// ، ftp(s)://
  • extract : يدعم حاليًا ملفات .zip و .gz و .tar .
  • download_and_extract : مثل dl_manager.extract(dl_manager.download(urls))

تقوم جميع هذه الأساليب بإرجاع tfds.core.Path (الأسماء المستعارة لـ epath.Path )، وهي كائنات تشبه pathlib.Path .

تدعم هذه التوابع بنية متداخلة اعتباطية ( list ، dict )، مثل:

extracted_paths = dl_manager.download_and_extract({
    'foo': 'https://example.com/foo.zip',
    'bar': 'https://example.com/bar.zip',
})
# This returns:
assert extracted_paths == {
    'foo': Path('/path/to/extracted_foo/'),
    'bar': Path('/path/extracted_bar/'),
}

التحميل والاستخراج يدويا

لا يمكن تنزيل بعض البيانات تلقائيًا (على سبيل المثال، يتطلب تسجيل الدخول)، وفي هذه الحالة، سيقوم المستخدم بتنزيل البيانات المصدر يدويًا ووضعها في manual_dir/ (الإعداد الافتراضي هو ~/tensorflow_datasets/downloads/manual/ ).

يمكن بعد ذلك الوصول إلى الملفات من خلال dl_manager.manual_dir :

class MyDataset(tfds.core.GeneratorBasedBuilder):

  MANUAL_DOWNLOAD_INSTRUCTIONS = """
  Register into https://example.org/login to get the data. Place the `data.zip`
  file in the `manual_dir/`.
  """

  def _split_generators(self, dl_manager):
    # data_path is a pathlib-like `Path('<manual_dir>/data.zip')`
    archive_path = dl_manager.manual_dir / 'data.zip'
    # Extract the manually downloaded `data.zip`
    extracted_path = dl_manager.extract(archive_path)
    ...

يمكن تخصيص موقع manual_dir باستخدام tfds build --manual_dir= أو باستخدام tfds.download.DownloadConfig .

قراءة الأرشيف مباشرة

يقوم dl_manager.iter_archive بقراءة الأرشيفات بشكل تسلسلي دون استخراجها. يمكن أن يؤدي ذلك إلى توفير مساحة التخزين وتحسين الأداء في بعض أنظمة الملفات.

for filename, fobj in dl_manager.iter_archive('path/to/archive.zip'):
  ...

يمتلك fobj نفس الأساليب المستخدمة with open('rb') as fobj: (على سبيل المثال fobj.read() )

تحديد تقسيمات مجموعة البيانات

إذا كانت مجموعة البيانات تأتي مع تقسيمات محددة مسبقًا (على سبيل المثال، تحتوي MNIST على تقسيمات train test )، فاحتفظ بها. بخلاف ذلك، قم بتحديد تقسيم all فقط. يمكن للمستخدمين إنشاء تقسيماتهم الفرعية ديناميكيًا باستخدام واجهة برمجة التطبيقات الخاصة بالتقسيم الفرعي (على سبيل المثال، split='train[80%:]' ). لاحظ أنه يمكن استخدام أي سلسلة أبجدية كاسم مقسم، بصرف النظر عن all المذكور أعلاه.

def _split_generators(self, dl_manager):
  # Download source data
  extracted_path = dl_manager.download_and_extract(...)

  # Specify the splits
  return {
      'train': self._generate_examples(
          images_path=extracted_path / 'train_imgs',
          label_path=extracted_path / 'train_labels.csv',
      ),
      'test': self._generate_examples(
          images_path=extracted_path / 'test_imgs',
          label_path=extracted_path / 'test_labels.csv',
      ),
  }

_generate_examples : مثال للمولد

_generate_examples ينشئ الأمثلة لكل تقسيم من البيانات المصدر.

ستقوم هذه الطريقة عادةً بقراءة عناصر مجموعة البيانات المصدر (مثل ملف CSV) وتنتج مجموعات (key, feature_dict) :

  • key : معرف المثال. يُستخدم لخلط الأمثلة بشكل حتمي باستخدام hash(key) أو للفرز حسب المفتاح عند تعطيل الخلط (راجع القسم الحفاظ على ترتيب مجموعة البيانات ). ينبغي أن يكون:
    • فريد : إذا استخدم مثالان نفس المفتاح، فسيتم ظهور استثناء.
    • حتمية : لا ينبغي أن تعتمد على download_dir ، ترتيب os.path.listdir ،... توليد البيانات مرتين يجب أن يؤدي إلى نفس المفتاح.
    • قابل للمقارنة : إذا تم تعطيل الخلط، فسيتم استخدام المفتاح لفرز مجموعة البيانات.
  • feature_dict : dict يحتوي على قيم المثال.
    • يجب أن تتطابق البنية مع features= البنية المحددة في tfds.core.DatasetInfo .
    • سيتم تشفير أنواع البيانات المعقدة (الصورة، الفيديو، الصوت،...) تلقائيًا.
    • غالبًا ما تقبل كل ميزة أنواعًا متعددة من الإدخال (مثل قبول الفيديو /path/to/vid.mp4 , np.array(shape=(l, h, w, c)) , List[paths] , List[np.array(shape=(h, w, c)] , List[img_bytes] ,...)
    • راجع دليل موصل الميزات لمزيد من المعلومات.
def _generate_examples(self, images_path, label_path):
  # Read the input data out of the source files
  with label_path.open() as f:
    for row in csv.DictReader(f):
      image_id = row['image_id']
      # And yield (key, feature_dict)
      yield image_id, {
          'image_description': row['description'],
          'image': images_path / f'{image_id}.jpeg',
          'label': row['label'],
      }

الوصول إلى الملفات و tf.io.gfile

من أجل دعم أنظمة التخزين السحابية، تجنب استخدام عمليات الإدخال/الإخراج المضمنة في Python.

بدلاً من ذلك، يقوم dl_manager بإرجاع كائنات تشبه pathlib المتوافقة مباشرة مع تخزين Google Cloud:

path = dl_manager.download_and_extract('http://some-website/my_data.zip')

json_path = path / 'data/file.json'

json.loads(json_path.read_text())

وبدلاً من ذلك، استخدم tf.io.gfile API بدلاً من المدمج في عمليات الملفات:

يجب تفضيل Pathlib على tf.io.gfile (راجع ملف عقلاني .

تبعيات اضافية

تتطلب بعض مجموعات البيانات تبعيات Python إضافية فقط أثناء الإنشاء. على سبيل المثال، تستخدم مجموعة بيانات SVHN scipy لتحميل بعض البيانات.

إذا كنت تضيف مجموعة بيانات إلى مستودع TFDS، فيرجى استخدام tfds.core.lazy_imports لإبقاء حزمة tensorflow-datasets صغيرة. سيقوم المستخدمون بتثبيت تبعيات إضافية حسب الحاجة فقط.

لاستخدام lazy_imports :

  • أضف إدخالاً لمجموعة البيانات الخاصة بك إلى DATASET_EXTRAS في setup.py . وهذا يجعل من الممكن للمستخدمين القيام، على سبيل المثال، pip install 'tensorflow-datasets[svhn]' لتثبيت التبعيات الإضافية.
  • قم بإضافة إدخال للاستيراد الخاص بك إلى LazyImporter وإلى LazyImportsTest .
  • استخدم tfds.core.lazy_imports للوصول إلى التبعية (على سبيل المثال، tfds.core.lazy_imports.scipy ) في DatasetBuilder الخاص بك.

بيانات تالفة

بعض مجموعات البيانات ليست نظيفة تمامًا وتحتوي على بعض البيانات الفاسدة (على سبيل المثال، الصور موجودة في ملفات JPEG ولكن بعضها JPEG غير صالح). يجب تخطي هذه الأمثلة، ولكن اترك ملاحظة في وصف مجموعة البيانات حول عدد الأمثلة التي تم إسقاطها ولماذا.

تكوين/متغيرات مجموعة البيانات (tfds.core.BuilderConfig)

قد تحتوي بعض مجموعات البيانات على متغيرات متعددة، أو خيارات لكيفية معالجة البيانات مسبقًا وكتابتها على القرص. على سبيل المثال، يحتوي Cycle_gan على تكوين واحد لكل زوج من الكائنات ( cycle_gan/horse2zebra ، cycle_gan/monet2photo ،...).

ويتم ذلك من خلال tfds.core.BuilderConfig s:

  1. قم بتعريف كائن التكوين الخاص بك كفئة فرعية من tfds.core.BuilderConfig . على سبيل المثال، MyDatasetConfig .

    @dataclasses.dataclass
    class MyDatasetConfig(tfds.core.BuilderConfig):
      img_size: Tuple[int, int] = (0, 0)
    
  2. حدد BUILDER_CONFIGS = [] عضو الفئة في MyDataset الذي يسرد MyDatasetConfig s التي تكشفها مجموعة البيانات.

    class MyDataset(tfds.core.GeneratorBasedBuilder):
      VERSION = tfds.core.Version('1.0.0')
      # pytype: disable=wrong-keyword-args
      BUILDER_CONFIGS = [
          # `name` (and optionally `description`) are required for each config
          MyDatasetConfig(name='small', description='Small ...', img_size=(8, 8)),
          MyDatasetConfig(name='big', description='Big ...', img_size=(32, 32)),
      ]
      # pytype: enable=wrong-keyword-args
    
  3. استخدم self.builder_config في MyDataset لتكوين إنشاء البيانات (على سبيل المثال، shape=self.builder_config.img_size ). قد يتضمن ذلك تعيين قيم مختلفة في _info() أو تغيير الوصول إلى بيانات التنزيل.

ملحوظات:

  • كل تكوين له اسم فريد. الاسم المؤهل بالكامل للتكوين هو dataset_name/config_name (على سبيل المثال coco/2017 ).
  • إذا لم يتم تحديده، فسيتم استخدام التكوين الأول في BUILDER_CONFIGS (على سبيل المثال، tfds.load('c4') الافتراضي هو c4/en )

راجع anli للحصول على مثال لمجموعة بيانات تستخدم BuilderConfig s.

إصدار

يمكن أن يشير الإصدار إلى معنيين مختلفين:

  • إصدار البيانات الأصلية "الخارجية": على سبيل المثال COCO v2019، v2017،...
  • إصدار كود TFDS "الداخلي": على سبيل المثال، إعادة تسمية ميزة في tfds.features.FeaturesDict ، وإصلاح الخلل في _generate_examples

لتحديث مجموعة بيانات:

  • بالنسبة لتحديث البيانات "الخارجية": قد يرغب عدة مستخدمين في الوصول إلى سنة/إصدار محدد في وقت واحد. يتم ذلك عن طريق استخدام tfds.core.BuilderConfig واحد لكل إصدار (على سبيل المثال coco/2017 , coco/2019 ) أو فئة واحدة لكل إصدار (على سبيل المثال Voc2007 , Voc2012 ).
  • بالنسبة لتحديث الكود "الداخلي": يقوم المستخدمون بتنزيل الإصدار الأحدث فقط. يجب أن يؤدي أي تحديث للتعليمات البرمجية إلى زيادة سمة فئة VERSION (على سبيل المثال، من 1.0.0 إلى VERSION = tfds.core.Version('2.0.0') ) بعد الإصدار الدلالي .

إضافة استيراد للتسجيل

لا تنس استيراد وحدة مجموعة البيانات إلى مشروعك __init__ ليتم تسجيلها تلقائيًا في tfds.load , tfds.builder .

import my_project.datasets.my_dataset  # Register MyDataset

ds = tfds.load('my_dataset')  # MyDataset available

على سبيل المثال، إذا كنت تساهم في tensorflow/datasets ، فأضف استيراد الوحدة النمطية إلى دليلها الفرعي __init__.py (على سبيل المثال image/__init__.py .

تحقق من وجود أخطاء التنفيذ الشائعة

يرجى التحقق من الأخطاء الشائعة في التنفيذ .

اختبر مجموعة البيانات الخاصة بك

التنزيل والتحضير: tfds build

لإنشاء مجموعة البيانات، قم بتشغيل tfds build من الدليل my_dataset/ :

cd path/to/datasets/my_dataset/
tfds build --register_checksums

بعض العلامات المفيدة للتنمية:

  • --pdb : أدخل في وضع التصحيح في حالة ظهور استثناء.
  • --overwrite : احذف الملفات الموجودة إذا تم إنشاء مجموعة البيانات بالفعل.
  • --max_examples_per_split : قم بإنشاء أمثلة X الأولى فقط (الافتراضي هو 1)، بدلاً من مجموعة البيانات الكاملة.
  • --register_checksums : سجل المجموع الاختباري لعناوين url التي تم تنزيلها. يجب استخدامه فقط أثناء التطوير.

راجع وثائق CLI للحصول على القائمة الكاملة للأعلام.

المجاميع الاختبارية

يوصى بتسجيل المجموع الاختباري لمجموعات البيانات الخاصة بك لضمان الحتمية والمساعدة في التوثيق... ويتم ذلك عن طريق إنشاء مجموعة البيانات باستخدام --register_checksums (راجع القسم السابق).

إذا كنت تقوم بإصدار مجموعات البيانات الخاصة بك من خلال PyPI، فلا تنس تصدير ملفات checksums.tsv (على سبيل المثال، في package_data الخاص بـ setup.py ).

وحدة اختبار مجموعة البيانات الخاصة بك

tfds.testing.DatasetBuilderTestCase عبارة عن TestCase أساسي لممارسة مجموعة البيانات بشكل كامل. ويستخدم "بيانات وهمية" كبيانات اختبار تحاكي بنية مجموعة البيانات المصدر.

  • يجب وضع بيانات الاختبار في الدليل my_dataset/dummy_data/ ويجب أن تحاكي عناصر مجموعة البيانات المصدر كما تم تنزيلها واستخراجها. يمكن إنشاؤه يدويًا أو تلقائيًا باستخدام برنامج نصي ( مثال للبرنامج النصي ).
  • تأكد من استخدام بيانات مختلفة في تقسيمات بيانات الاختبار، حيث سيفشل الاختبار إذا تداخلت تقسيمات مجموعة البيانات.
  • يجب ألا تحتوي بيانات الاختبار على أي مواد محمية بحقوق الطبع والنشر . إذا كان لديك شك، فلا تقم بإنشاء البيانات باستخدام مواد من مجموعة البيانات الأصلية.
import tensorflow_datasets as tfds
from . import my_dataset_dataset_builder


class MyDatasetTest(tfds.testing.DatasetBuilderTestCase):
  """Tests for my_dataset dataset."""
  DATASET_CLASS = my_dataset_dataset_builder.Builder
  SPLITS = {
      'train': 3,  # Number of fake train example
      'test': 1,  # Number of fake test example
  }

  # If you are calling `download/download_and_extract` with a dict, like:
  #   dl_manager.download({'some_key': 'http://a.org/out.txt', ...})
  # then the tests needs to provide the fake output paths relative to the
  # fake data directory
  DL_EXTRACT_RESULT = {
      'name1': 'path/to/file1',  # Relative to my_dataset/dummy_data dir.
      'name2': 'file2',
  }


if __name__ == '__main__':
  tfds.testing.test_main()

قم بتشغيل الأمر التالي لاختبار مجموعة البيانات.

python my_dataset_test.py

أرسل لنا ردود الفعل

نحن نحاول باستمرار تحسين سير عمل إنشاء مجموعة البيانات، ولكن لا يمكننا القيام بذلك إلا إذا كنا على دراية بالمشكلات. ما المشكلات أو الأخطاء التي واجهتها أثناء إنشاء مجموعة البيانات؟ هل كان هناك جزء مربك، أو لم يكن يعمل في المرة الأولى؟

يرجى مشاركة ملاحظاتك على جيثب .